mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 15:36:22 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f50581bb2 | ||
|
|
adef14547a | ||
|
|
891c269450 | ||
|
|
878cf6ed0e | ||
|
|
d878dc1831 | ||
|
|
d7cd297456 | ||
|
|
6407ab2b26 | ||
|
|
79ad203e11 | ||
|
|
ad3b2c83f9 | ||
|
|
b6b87be74e | ||
|
|
09d9ec1a76 | ||
|
|
bc062b4cbf | ||
|
|
e0ea1a8e44 | ||
|
|
aa21940e12 | ||
|
|
329b6a9624 | ||
|
|
b16bc26e6e | ||
|
|
9229764a2d | ||
|
|
a86665f009 | ||
|
|
5e0750282a | ||
|
|
ea71b0400b | ||
|
|
d0f23fa230 | ||
|
|
d5887fc5c4 | ||
|
|
52964dda33 | ||
|
|
b5d423546b | ||
|
|
a79594c72d | ||
|
|
fba7aaaabb | ||
|
|
c5d6150fe9 | ||
|
|
71cece1a99 | ||
|
|
60ab72d884 | ||
|
|
cde1fc4c6f | ||
|
|
c026023822 | ||
|
|
952387176e | ||
|
|
0b980a1ac0 | ||
|
|
a73f76b368 | ||
|
|
23b6318fd6 | ||
|
|
6f6fc10847 | ||
|
|
d2bec894bf | ||
|
|
d7009156a6 | ||
|
|
4cfca7ab68 | ||
|
|
ae7d9f029d | ||
|
|
9acdb0be18 | ||
|
|
b42555cf01 | ||
|
|
cfe9f13963 | ||
|
|
48f246695e | ||
|
|
4f6060ad94 | ||
|
|
bf6ec61a74 | ||
|
|
cf298f3409 | ||
|
|
0089ac9b02 | ||
|
|
4680a4aa6f | ||
|
|
88e4ab5735 | ||
|
|
efefd90443 | ||
|
|
2b87169cc1 | ||
|
|
962c26bdf2 | ||
|
|
22de01e84f | ||
|
|
8565cb52a1 | ||
|
|
050398f51f | ||
|
|
5f1d74fd3f | ||
|
|
d9c85d8d95 | ||
|
|
1c77b1c920 | ||
|
|
27f838f249 |
@@ -62,6 +62,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -358,6 +359,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -416,6 +418,8 @@
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
@@ -423,6 +427,8 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -433,6 +439,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
@@ -605,6 +612,21 @@
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode-ai/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode-ai/posts",
|
||||
"dependencies": {
|
||||
@@ -674,6 +696,7 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -1046,6 +1069,7 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
@@ -1823,6 +1847,16 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
@@ -2143,6 +2177,8 @@
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
@@ -2207,6 +2243,8 @@
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="],
|
||||
|
||||
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
||||
@@ -2217,6 +2255,8 @@
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="],
|
||||
@@ -2489,6 +2529,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="],
|
||||
|
||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="],
|
||||
|
||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
||||
@@ -2545,6 +2587,8 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
|
||||
@@ -2743,10 +2787,18 @@
|
||||
|
||||
"@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="],
|
||||
|
||||
"@sentry/node": ["@sentry/node@10.72.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/node-core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "@sentry/server-utils": "10.72.0", "import-in-the-middle": "^3.0.0" } }, "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow=="],
|
||||
|
||||
"@sentry/node-core": ["@sentry/node-core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ=="],
|
||||
|
||||
"@sentry/opentelemetry": ["@sentry/opentelemetry@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w=="],
|
||||
|
||||
"@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="],
|
||||
|
||||
"@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="],
|
||||
|
||||
"@sentry/server-utils": ["@sentry/server-utils@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" } }, "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w=="],
|
||||
|
||||
"@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="],
|
||||
|
||||
"@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="],
|
||||
@@ -3313,6 +3365,8 @@
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -3541,10 +3595,16 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
|
||||
|
||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
@@ -3601,6 +3661,8 @@
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
@@ -3637,6 +3699,8 @@
|
||||
|
||||
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
|
||||
|
||||
"csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
|
||||
@@ -3741,6 +3805,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
@@ -3789,6 +3855,8 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="],
|
||||
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="],
|
||||
@@ -3893,6 +3961,8 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
@@ -3937,7 +4007,7 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
@@ -4239,6 +4309,8 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
||||
@@ -4263,8 +4335,12 @@
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
"image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="],
|
||||
|
||||
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -4283,6 +4359,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||
|
||||
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -4401,12 +4479,16 @@
|
||||
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
|
||||
|
||||
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="],
|
||||
|
||||
"js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||
@@ -4479,8 +4561,16 @@
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="],
|
||||
|
||||
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||
|
||||
"lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="],
|
||||
|
||||
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
|
||||
|
||||
"lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
@@ -4527,6 +4617,8 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
@@ -4559,6 +4651,8 @@
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -4719,8 +4813,14 @@
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="],
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
@@ -5031,6 +5131,8 @@
|
||||
|
||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
@@ -5157,6 +5259,8 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||
|
||||
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
||||
|
||||
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||
@@ -5191,6 +5295,8 @@
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
|
||||
@@ -5349,6 +5455,8 @@
|
||||
|
||||
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
||||
|
||||
"speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
@@ -5483,6 +5591,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="],
|
||||
|
||||
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
@@ -5505,6 +5615,10 @@
|
||||
|
||||
"titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
|
||||
|
||||
"tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="],
|
||||
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||
@@ -5567,6 +5681,8 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
@@ -5727,6 +5843,8 @@
|
||||
|
||||
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
|
||||
|
||||
"web-features": ["web-features@3.36.0", "", {}, "sha512-B5W9HbyXT76soRG7Fn3AELZkuDQpmpSxszSmJZLIPUHYxIGkufNz0vcoEhfqnUF1HAfaducWIBaaKuusq9Am2g=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
@@ -5735,6 +5853,8 @@
|
||||
|
||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
@@ -5811,6 +5931,8 @@
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
@@ -6283,6 +6405,12 @@
|
||||
|
||||
"@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
|
||||
|
||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
@@ -6315,6 +6443,8 @@
|
||||
|
||||
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
@@ -6325,6 +6455,18 @@
|
||||
|
||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="],
|
||||
|
||||
"@sentry/node/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
|
||||
|
||||
"@sentry/node-core/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
|
||||
|
||||
"@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
|
||||
|
||||
"@sentry/server-utils/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
@@ -6433,6 +6575,10 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"cliui/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=="],
|
||||
@@ -6451,6 +6597,8 @@
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
@@ -6495,6 +6643,8 @@
|
||||
|
||||
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6539,6 +6689,8 @@
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6547,10 +6699,16 @@
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
|
||||
|
||||
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -6615,6 +6773,10 @@
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="],
|
||||
|
||||
"puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6683,6 +6845,8 @@
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
||||
@@ -7201,6 +7365,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
@@ -7209,10 +7375,20 @@
|
||||
|
||||
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
|
||||
@@ -7381,6 +7557,8 @@
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7417,6 +7595,8 @@
|
||||
|
||||
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
@@ -7439,6 +7619,12 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
@@ -8183,6 +8369,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
|
||||
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
|
||||
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
|
||||
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
|
||||
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
|
||||
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
|
||||
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
|
||||
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,11 @@ export const OpenResponsesUsage = Schema.Struct({
|
||||
})
|
||||
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
|
||||
|
||||
// The spec requires `id` on every output item, but some gateways drop it from
|
||||
// later item events (Bedrock Mantle renames it to `item_id` on
|
||||
// `output_item.done` and `response.completed.output`). Decode it as optional
|
||||
// and let `normalize` recover or mint it once before the parser runs.
|
||||
// https://www.openresponses.org/specification#extending-items
|
||||
export const StreamItem = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
@@ -316,6 +321,7 @@ export const StreamItem = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
@@ -395,6 +401,7 @@ export const Event = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
@@ -416,14 +423,13 @@ export interface ParserState {
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<string>
|
||||
// Call ids stay independent of item ids, which may be omitted or reused.
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedTools: ReadonlySet<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedMessages: ReadonlySet<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
@@ -875,9 +881,6 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
|
||||
[state.providerMetadataKey]: metadata,
|
||||
})
|
||||
|
||||
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
|
||||
item.type === "reasoning" && typeof item.id === "string"
|
||||
|
||||
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
@@ -921,9 +924,34 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
message: "msg",
|
||||
reasoning: "rs",
|
||||
function_call: "fc",
|
||||
compaction: "cmp",
|
||||
}
|
||||
|
||||
// An item without an id adopts the id already open in its output slot,
|
||||
// otherwise it gets a locally minted one.
|
||||
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
|
||||
...item,
|
||||
id:
|
||||
item.id ??
|
||||
(index === undefined ? undefined : state.outputItems[index]) ??
|
||||
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
|
||||
})
|
||||
|
||||
// Registered output slots are authoritative for `item_id` routing, and items
|
||||
// are resolved here so everything downstream can rely on `item.id`.
|
||||
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
|
||||
...input,
|
||||
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
|
||||
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
|
||||
})
|
||||
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
@@ -997,7 +1025,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
}
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs normally stream reasoning items in this order:
|
||||
@@ -1010,18 +1038,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
|
||||
// also an implicit boundary for the previous part. This keeps the common event
|
||||
// lifecycle ordered when a compatible provider omits or delays a part-done event.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (item.type === "message") {
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
|
||||
const phase = messagePhase(item.phase)
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
|
||||
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.filter((id) => id !== item.id)
|
||||
.reduce((lifecycle, id) => {
|
||||
completedMessages.add(id)
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
@@ -1038,14 +1066,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
id: item.id,
|
||||
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
if (item.type === "reasoning") {
|
||||
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1065,18 +1093,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
const id = item.id ?? item.call_id
|
||||
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
|
||||
return [state, NO_EVENTS]
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, id, {
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
input: item.arguments ?? "",
|
||||
@@ -1148,13 +1174,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: Event["item"],
|
||||
item: NormalizedEvent["item"],
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "compaction") {
|
||||
if (!item.id || typeof item.encrypted_content !== "string")
|
||||
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
|
||||
if (typeof item.encrypted_content !== "string")
|
||||
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
|
||||
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
@@ -1171,7 +1197,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
if (item.type === "message") {
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
completedMessages.add(item.id)
|
||||
@@ -1204,36 +1230,23 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const callID = item.call_id
|
||||
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const fallback = item.id ?? callID
|
||||
// Match the pending tool by call id so item events that disagree on
|
||||
// whether `item.id` is present still resolve the same call.
|
||||
const registered =
|
||||
state.tools[fallback] !== undefined
|
||||
? fallback
|
||||
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
|
||||
const id = registered ?? fallback
|
||||
const tools =
|
||||
registered !== undefined
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: callID,
|
||||
name: item.name,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const registered = state.tools[item.id] !== undefined
|
||||
const tools = registered
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
|
||||
const events: LLMEvent[] = []
|
||||
const finished = result.events ?? []
|
||||
// A done-only call never streamed a start event, so open its lifecycle here.
|
||||
const resultEvents =
|
||||
registered !== undefined || finished.length === 0
|
||||
registered || finished.length === 0
|
||||
? finished
|
||||
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
|
||||
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
@@ -1244,13 +1257,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
completedTools: new Set([...state.completedTools, callID]),
|
||||
completedTools: new Set([...state.completedTools, item.id]),
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
if (item.type === "reasoning") {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
@@ -1334,21 +1347,17 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
if (item.type !== "compaction" && item.type !== "function_call") continue
|
||||
if (item.type === "compaction") {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
}
|
||||
if (
|
||||
item.type === "function_call" &&
|
||||
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
|
||||
)
|
||||
continue
|
||||
// An output item's array position is its output index.
|
||||
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
const recoverable =
|
||||
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
|
||||
if (!recoverable) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
@@ -1415,12 +1424,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
|
||||
return new AIError({ reason })
|
||||
}
|
||||
|
||||
export const step = (state: ParserState, input: Event) => {
|
||||
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
|
||||
const event =
|
||||
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
|
||||
? { ...input, item_id: outputItemID(state, input) }
|
||||
: input
|
||||
// Callers must pass events through `normalize` first. The OpenAPI requires
|
||||
// string IDs but imposes no minLength; empty is not missing.
|
||||
export const step = (state: ParserState, event: NormalizedEvent) => {
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
@@ -1460,20 +1466,16 @@ export const step = (state: ParserState, input: Event) => {
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
if (
|
||||
event.item &&
|
||||
isReasoningItem(event.item) &&
|
||||
event.item?.type === "reasoning" &&
|
||||
state.reasoningItems[event.item.id] === undefined &&
|
||||
state.lifecycle.reasoning.size > 0
|
||||
)
|
||||
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
|
||||
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
event.output_index !== undefined && id !== undefined
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
|
||||
event.output_index !== undefined && event.item
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
|
||||
: state,
|
||||
event,
|
||||
),
|
||||
@@ -1483,11 +1485,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
return event.item_id !== undefined
|
||||
? onFunctionCallArgumentsDelta(state, event)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event.item)
|
||||
}
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
@@ -1537,7 +1535,7 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(Event),
|
||||
initial,
|
||||
step,
|
||||
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
|
||||
terminal,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -201,12 +201,11 @@ const HOSTED_TOOLS = {
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
|
||||
)
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
|
||||
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { Lifecycle } from "./lifecycle.js"
|
||||
|
||||
export type Item = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
export type Item = OpenResponses.OutputItem & {
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
@@ -27,8 +26,8 @@ export interface Definition {
|
||||
|
||||
export type Definitions = Readonly<Record<string, Definition>>
|
||||
|
||||
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
|
||||
item.type in tools && typeof item.id === "string" && item.id.length > 0
|
||||
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
|
||||
item.type in tools
|
||||
|
||||
export const onDone: (
|
||||
state: OpenResponses.ParserState,
|
||||
|
||||
@@ -69,7 +69,8 @@ const HOSTED_TOOLS = {
|
||||
|
||||
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
|
||||
// handled by the baseline); only its hosted tool vocabulary differs.
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
|
||||
const event = OpenResponses.normalize(state, input)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -125,12 +125,15 @@ testEffect(
|
||||
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
|
||||
}),
|
||||
),
|
||||
).effect("rejects terminal checkpoints missing an id", () =>
|
||||
).effect("mints an id for terminal checkpoints that omit one", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("missing its id")
|
||||
)
|
||||
const part = response.message.content[0]
|
||||
expect(part?.type).toBe("compaction")
|
||||
if (part?.type !== "compaction") return
|
||||
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
|
||||
expect(part.encrypted).toBe("opaque")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -329,69 +329,126 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
|
||||
{ type: "response.output_item.added", item },
|
||||
completed,
|
||||
)
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
|
||||
// items rename `id` to `item_id` and carry a stray `output_index`.
|
||||
it.effect("recovers a terminal function_call id from its output slot", () =>
|
||||
Effect.gen(function* () {
|
||||
const terminal = {
|
||||
type: "function_call",
|
||||
item_id: "fc_828bee50dee1d029",
|
||||
call_id: "call_bc1eb4b42e70ee53",
|
||||
name: "get_weather",
|
||||
arguments: '{\n "city": "Paris"\n}',
|
||||
output_index: 1,
|
||||
status: "completed",
|
||||
}
|
||||
const events = yield* collect(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
|
||||
},
|
||||
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_828bee50dee1d029",
|
||||
call_id: "call_bc1eb4b42e70ee53",
|
||||
name: "get_weather",
|
||||
arguments: "",
|
||||
status: "in_progress",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 1,
|
||||
item_id: "fc_828bee50dee1d029",
|
||||
delta: '{\n "city": "Paris"\n}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 1,
|
||||
item_id: "fc_828bee50dee1d029",
|
||||
arguments: '{\n "city": "Paris"\n}',
|
||||
},
|
||||
{ type: "response.output_item.done", output_index: 1, item: terminal },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
|
||||
},
|
||||
)
|
||||
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_bc1eb4b42e70ee53",
|
||||
name: "get_weather",
|
||||
text: '{\n "city": "Paris"\n}',
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_bc1eb4b42e70ee53",
|
||||
name: "get_weather",
|
||||
input: { city: "Paris" },
|
||||
providerMetadata,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
|
||||
const first = { ...item, ...(id === undefined ? {} : { id }) }
|
||||
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
{ type: "response.output_item.done", item: duplicate },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
completed,
|
||||
)
|
||||
// Identity metadata comes from the first admission, not the duplicate.
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
it.effect("mints an id for a done-only tool that never had one", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
const call = events.find(LLMEvent.is.toolCall)
|
||||
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
|
||||
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
|
||||
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("opens and closes a done-only tool once", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
completed,
|
||||
)
|
||||
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -436,21 +493,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves call identity and pending order when an item id is reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
@@ -500,14 +542,15 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
// Generic terminal closure does not repeat the message's phase metadata.
|
||||
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
|
||||
expect(events.slice(4, -2)).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -586,23 +586,21 @@ describe("Open Responses-compatible route", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
|
||||
Effect.forEach(fixtures, (fixture) =>
|
||||
Effect.forEach(
|
||||
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
|
||||
(id) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type, item: { ...fixture.item, id } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
Effect.forEach([null, 0, false, {}, []], (id) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type, item: { ...fixture.item, id } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -610,43 +608,6 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("streams function calls without optional item ids through the shared baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 1,
|
||||
item_id: "opaque_item",
|
||||
delta: '{"query":"shared"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: { ...item, arguments: '{"query":"complete"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes pending function calls from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an item-id-less tool call with only the new tool output", () =>
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
@@ -2129,47 +2130,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 2,
|
||||
item_id: "opaque_delta",
|
||||
delta: '{"query":"streamed"}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 2,
|
||||
item_id: "opaque_done",
|
||||
arguments: '{"query":"arguments-done"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 2,
|
||||
item: { ...item, arguments: '{"query":"output-item-done"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
|
||||
{ id: "call_1", text: '{"query":"streamed"}' },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes reasoning summary events by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
event,
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
@@ -2931,14 +2891,10 @@ describe("OpenAI Responses route", () => {
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
// Duplicates that drop the item id still resolve the same call.
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
// A completed item that is re-added stays closed.
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
@@ -3793,43 +3749,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes and replays a completed function call without an optional item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits only missing function arguments from the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -4017,7 +3936,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -4032,6 +3951,7 @@ describe("OpenAI Responses route", () => {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
@@ -4053,37 +3973,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
item_id: "opaque_delta",
|
||||
delta: '{"query":"partial',
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets completed response output override arguments done", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -122,7 +123,7 @@ export async function setupTimeline(
|
||||
messages?: TimelineMessage[]
|
||||
sessionMessages?: SessionMessageInfo[]
|
||||
sessionStatus?: Record<string, SessionStatus>
|
||||
settings?: Record<string, boolean>
|
||||
settings?: Record<string, boolean | TimelineDetail>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
viewport?: { width: number; height: number }
|
||||
|
||||
@@ -92,6 +92,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
@@ -114,6 +116,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect(openFile).toBeVisible()
|
||||
|
||||
@@ -67,6 +67,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
|
||||
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
|
||||
@@ -87,6 +88,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
|
||||
.toBe(true)
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
@@ -30,9 +30,11 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
|
||||
await expect(sessionHeading).toBeAttached()
|
||||
await expect(sessionHeading).toBeHidden()
|
||||
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
@@ -66,6 +68,15 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ test("follows a live session move while the agent catalog is still loading", asy
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
|
||||
)
|
||||
})
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
@@ -95,9 +101,7 @@ test("follows a live session move while the agent catalog is still loading", asy
|
||||
await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
@@ -221,7 +225,8 @@ function recoveryRequests(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
|
||||
requests.push(path)
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
@@ -16,6 +17,9 @@ for (const width of [1400, 390]) {
|
||||
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
|
||||
]
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
locale: profile.locale,
|
||||
viewport: { width, height: 900 },
|
||||
sessionMessages: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
@@ -36,7 +37,9 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
@@ -68,7 +71,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
||||
await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
assistantMessage([
|
||||
@@ -100,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const row = page.locator("[data-timeline-key]", { has: wrapper })
|
||||
const trigger = wrapper.getByRole("button")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
@@ -23,20 +24,23 @@ for (const expanded of [false, true]) {
|
||||
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
||||
const id = `prt_shell_default_${expanded}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
|
||||
settings: {
|
||||
timelineDetail: {
|
||||
...timelinePresets[2].value,
|
||||
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
|
||||
},
|
||||
},
|
||||
})
|
||||
const trigger = expanded
|
||||
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 250)
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
|
||||
await timeline.send(status("idle"))
|
||||
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
})
|
||||
}
|
||||
@@ -46,6 +50,9 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
const command = "printf ready"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
|
||||
|
||||
@@ -95,7 +102,9 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
const command = "sleep 10 && echo done"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
@@ -110,7 +119,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
})
|
||||
|
||||
for (const open of [false, true]) {
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reasoningID = `prt_reasoning_hidden_${open}`
|
||||
@@ -118,7 +127,13 @@ for (const open of [false, true]) {
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
settings: {
|
||||
timelineDetail: {
|
||||
...timelinePresets[2].value,
|
||||
thinking: { placement: "separate", details: "collapsed" },
|
||||
shell: { placement: "separate", details: "collapsed" },
|
||||
},
|
||||
},
|
||||
cpuRate: 4,
|
||||
})
|
||||
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
|
||||
@@ -141,28 +156,11 @@ for (const open of [false, true]) {
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,6 +170,9 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
|
||||
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
@@ -49,6 +50,9 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
@@ -84,7 +88,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(true)],
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
@@ -161,7 +170,12 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(false, true)],
|
||||
})
|
||||
const card = page.locator('[data-component="task-tool-card"]')
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toContainText("Inspect code")
|
||||
@@ -198,6 +212,9 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
@@ -213,6 +230,7 @@ for (const name of ["shell", "subagent"] as const) {
|
||||
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
|
||||
const message = assistant(false, true)
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
@@ -281,6 +299,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
@@ -369,6 +388,13 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
await expect(summary).toContainText("2")
|
||||
@@ -376,6 +402,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
await expect(list).toContainText("Background task")
|
||||
await expect(list).toContainText("sleep 120")
|
||||
await expect(list).not.toContainText("Foreground task")
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
@@ -14,7 +15,9 @@ test.describe("session timeline projection", () => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
@@ -34,6 +37,7 @@ test.describe("session timeline projection", () => {
|
||||
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
|
||||
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
await expect(initialFile).toBeVisible()
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await initialFile.getByRole("button").click()
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await initial.evaluate((element) => {
|
||||
@@ -110,8 +114,11 @@ test.describe("session timeline projection", () => {
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
||||
await timeline.send(status("idle"), 100)
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
|
||||
})
|
||||
await timeline.send(status("idle"))
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
@@ -127,6 +134,7 @@ test.describe("session timeline projection", () => {
|
||||
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
|
||||
await setupTimeline(page, {
|
||||
viewport: { width: 420, height: 700 },
|
||||
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
|
||||
sessionMessages: [
|
||||
{
|
||||
id: "msg_model_fast_nano",
|
||||
|
||||
@@ -5,134 +5,48 @@ import {
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
|
||||
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
reasoningPart(
|
||||
"prt_reasoning_settings",
|
||||
"## Inspecting stability\n\nThe selected mode controls these details.",
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
assistantMessage([
|
||||
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
|
||||
for (const label of ["Full", "Hidden", "Compact"] as const) {
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await select.click()
|
||||
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
|
||||
await page.getByRole("option", { name: label, exact: true }).click()
|
||||
await expect(select).toHaveText(label)
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
|
||||
.toBe(label.toLowerCase())
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
if (label === "Hidden") {
|
||||
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
continue
|
||||
}
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
|
||||
if (label === "Full")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
if (label === "Compact") {
|
||||
await expect(part.getByRole("button")).toContainText("Inspecting stability")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
}
|
||||
}
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(select).toHaveText("Compact")
|
||||
})
|
||||
|
||||
// The persisted boolean migrates to compact (false) or full (true).
|
||||
for (const summaries of [false, true]) {
|
||||
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
|
||||
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
...(profile === "none"
|
||||
? []
|
||||
: [
|
||||
reasoningPart(
|
||||
`prt_reasoning_${summaries}_${profile}`,
|
||||
profile === "blank"
|
||||
? " "
|
||||
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
|
||||
),
|
||||
]),
|
||||
...(profile === "tool"
|
||||
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { showReasoningSummaries: summaries },
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
|
||||
profile === "blank" || profile === "heading" ? 1 : 0,
|
||||
)
|
||||
if (profile === "none") {
|
||||
await expect(part).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "blank") {
|
||||
await expect(part).toContainText("Thinking")
|
||||
await expect(part.getByRole("heading")).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "tool") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^Used\s*1 Skill$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
|
||||
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
|
||||
}
|
||||
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
|
||||
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
|
||||
if (!summaries) {
|
||||
await expect(body).toBeHidden()
|
||||
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await expect(body).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(body).toBeHidden()
|
||||
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
|
||||
})
|
||||
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()) {
|
||||
if (index) await slider.press("ArrowRight")
|
||||
await expect(slider).toHaveValue(String(index))
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", name)
|
||||
}
|
||||
}
|
||||
await slider.press("Home")
|
||||
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()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
|
||||
)
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
await timeline.send(status("busy"))
|
||||
await timeline.send(status("idle"))
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
@@ -161,12 +161,14 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await glob.getByRole("button").click()
|
||||
await expect(glob).toContainText("Invalid tool input")
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
@@ -180,6 +182,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
.evaluate((element) => getComputedStyle(element, "::before").display),
|
||||
)
|
||||
.toBe("none")
|
||||
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
|
||||
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
@@ -74,21 +75,29 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/outline.ts" },
|
||||
{ metadata: { files: [file] } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
@@ -106,8 +115,6 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
@@ -7,11 +8,11 @@ import {
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
test("keeps shell and question failures in their Used group", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -23,30 +24,46 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
),
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await used.click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
partUpdated(
|
||||
toolPart(
|
||||
shellID,
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "exit 1" },
|
||||
{ output: "Command exited 1", metadata: { exit: 1 } },
|
||||
),
|
||||
),
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
||||
),
|
||||
250,
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await shell.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(shell).toContainText("Command exited 1")
|
||||
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
await expect(question).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
|
||||
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
|
||||
const failed = "prt_grouped_patch_failed"
|
||||
const surviving = "prt_grouped_patch_surviving"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -77,9 +94,12 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await used.click()
|
||||
const file = group.locator('[data-scope="apply-patch"] button')
|
||||
await expect(file).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await group.evaluate((element) => {
|
||||
@@ -99,23 +119,20 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
const survivingRow = page.locator("[data-timeline-key]", {
|
||||
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
|
||||
})
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
|
||||
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
|
||||
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
|
||||
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
|
||||
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
|
||||
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
|
||||
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const previous = await failedRow.boundingBox()
|
||||
const next = await survivingRow.boundingBox()
|
||||
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
|
||||
})
|
||||
.toBeGreaterThanOrEqual(-0.5)
|
||||
})
|
||||
|
||||
test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
const id = "prt_read_instructions"
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
@@ -148,7 +165,10 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [userMessage(), assistantMessage(parts)],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
|
||||
await group.getByRole("button").click()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
@@ -19,6 +20,9 @@ for (const width of [1400, 390]) {
|
||||
messages: [userMessage()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
viewport: { width, height: 900 },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(1)
|
||||
@@ -51,7 +55,9 @@ for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
@@ -89,13 +95,8 @@ for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
|
||||
if (name === "shell") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(
|
||||
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
|
||||
).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group).toBeVisible()
|
||||
}
|
||||
await expect(tool).toBeVisible()
|
||||
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
@@ -259,6 +260,7 @@ for (const failed of [false, true]) {
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Check the working indicator immediately.")
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
|
||||
@@ -32,6 +32,24 @@ test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("settings has its own route and returns through app history", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const home = page.getByRole("button", { name: "Home", exact: true })
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(home).toHaveAttribute("aria-pressed", "false")
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await expect(home).toHaveAttribute("aria-pressed", "false")
|
||||
await home.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
|
||||
@@ -29,7 +29,7 @@ test("new session tab matches neighboring session widths", async ({ page }, test
|
||||
await page.goto(href)
|
||||
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
|
||||
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
|
||||
await testInfo.attach("new-session-between-tabs", {
|
||||
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
|
||||
contentType: "image/png",
|
||||
@@ -194,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
|
||||
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
|
||||
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
}),
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||
findFiles: () => ["NewProject"],
|
||||
// Listings are requested by absolute path and returned relative to the stable Location.
|
||||
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||
await expectAppVisible(addProject)
|
||||
await addProject.click()
|
||||
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
|
||||
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
|
||||
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
|
||||
const listing = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
|
||||
})
|
||||
await picker.getByRole("button", { name: "Parent", exact: true }).click()
|
||||
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
|
||||
await expect(directoryItem).toBeVisible()
|
||||
await directoryItem.click()
|
||||
const selectFolder = page.getByRole("button", { name: "Select folder" })
|
||||
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
|
||||
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
|
||||
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
|
||||
await expect(selectFolder).toBeEnabled()
|
||||
await selectFolder.click()
|
||||
await expect(picker).toBeHidden()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
if (!connection) return
|
||||
pickDirectory({
|
||||
server: connection,
|
||||
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
|
||||
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
data-component="new-session"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, authorization: response.data }))
|
||||
.then((response) => {
|
||||
if (options.provider() === "opencode" && platform.platform === "desktop") {
|
||||
const url = new URL(response.data.url)
|
||||
url.searchParams.set("client_id", "opencode-desktop")
|
||||
response.data.url = url.href
|
||||
}
|
||||
return { ok: true as const, authorization: response.data }
|
||||
})
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -665,9 +665,11 @@ export const dict = {
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.browser": "Browser",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.panel.reviewAndFiles": "Review, files, and browser",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
@@ -806,6 +808,9 @@ export const dict = {
|
||||
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
@@ -951,6 +956,40 @@ export const dict = {
|
||||
"settings.general.section.feed": "Feed",
|
||||
"settings.general.section.display": "Display",
|
||||
|
||||
"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.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.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.",
|
||||
"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.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",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
@@ -975,6 +1014,8 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
@@ -1164,6 +1205,9 @@ export const dict = {
|
||||
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
"settings.permissions.tool.websearch.description": "Search the web",
|
||||
"settings.permissions.tool.browser_read.description": "Read pages and capture screenshots in the browser",
|
||||
"settings.permissions.tool.browser_navigate.description": "Navigate the browser to a URL",
|
||||
"settings.permissions.tool.browser_interact.description": "Click, type, and interact with pages in the browser",
|
||||
"settings.permissions.tool.external_directory.title": "External Directory",
|
||||
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
|
||||
"settings.permissions.tool.doom_loop.title": "Doom Loop",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = {
|
||||
tabID: Browser.TabID
|
||||
visible: boolean
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -115,6 +116,9 @@ type PlatformBase = {
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -230,8 +230,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
if (event.type === "reference.updated" && children.active(key))
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [state, setState] = createStore({
|
||||
registration: undefined as BrowserPaneRegistration | undefined,
|
||||
browser: null as BrowserPaneState,
|
||||
error: undefined as string | undefined,
|
||||
// The connected server has no browser plugin.
|
||||
unsupported: false,
|
||||
})
|
||||
const available = createMemo(
|
||||
() =>
|
||||
!!platform.browserPane &&
|
||||
settings.ready() &&
|
||||
settings.general.experimentalBrowser() &&
|
||||
session.isDesktop() &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible &&
|
||||
!state.unsupported,
|
||||
)
|
||||
const browserTabs = createMemo(
|
||||
() => state.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ?? [],
|
||||
)
|
||||
const opened = () => state.registration !== undefined && browserTabs().length > 0
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
setState("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void state.registration?.command(command).catch(() => {
|
||||
if (owner.current()) setState("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().active(),
|
||||
(active) => {
|
||||
const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
|
||||
if (tab && tab.id !== state.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
|
||||
.forEach((key) => {
|
||||
const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
|
||||
if (tab) command({ type: "tabs.close", tabID: tab.id })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
const pane = platform.browserPane
|
||||
setState({ registration: undefined, browser: null, error: undefined })
|
||||
if (!available() || !sessionID || !pane) return
|
||||
const owner = session.ownership.capture()
|
||||
const target = { sessionID, endpoint: server.conn.http }
|
||||
let registration: BrowserPaneRegistration | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
let attempts = 0
|
||||
const register = () => {
|
||||
if (registration) return
|
||||
registration = pane.register(target, (event) =>
|
||||
owner.run(() => {
|
||||
if (event.type === "focus") return focus(event.tabID)
|
||||
if (event.error === "browser.pane.unsupported") return setState("unsupported", true)
|
||||
// The desktop dropped the attachment (server restart, attach race).
|
||||
// Re-register so the agent's browser tool comes back without a reload.
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
registration?.close()
|
||||
registration = undefined
|
||||
setState({ registration: undefined, browser: null, error: undefined })
|
||||
retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** attempts++))
|
||||
return
|
||||
}
|
||||
if (event.state) attempts = 0
|
||||
batch(() => {
|
||||
const known = new Set(state.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? [])
|
||||
setState("browser", reconcile(event.state))
|
||||
setState("error", event.error ? language.t("common.requestFailed") : undefined)
|
||||
const tabs = session.layout.tabs()
|
||||
const ids = event.state?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? []
|
||||
tabs
|
||||
.all()
|
||||
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
|
||||
.forEach(tabs.close)
|
||||
const current = tabs.all()
|
||||
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
|
||||
if (added.length) tabs.setAll([...current, ...added])
|
||||
})
|
||||
}),
|
||||
)
|
||||
setState({ registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes.
|
||||
const unsubscribe = session.shared.data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
})
|
||||
if (!session.shared.data.session.creating(sessionID)) register()
|
||||
onCleanup(() => {
|
||||
unsubscribe()
|
||||
clearTimeout(retry)
|
||||
registration?.close()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
available,
|
||||
opened,
|
||||
state: () => state.browser,
|
||||
tabs: browserTabs,
|
||||
active: () =>
|
||||
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
|
||||
browserTabs().find((tab) => tab.id === state.browser?.focusedTabID) ??
|
||||
browserTabs()[0],
|
||||
error: () => state.error,
|
||||
registration: () => state.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Loader } from "@opencode-ai/ui/loader"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.active
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const measure = () => {
|
||||
frame = undefined
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
if (!tab) {
|
||||
props.registration.setLayout()
|
||||
return
|
||||
}
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
props.registration.setLayout({
|
||||
tabID: tab.id,
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
})
|
||||
}
|
||||
if (performance.now() < until) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => platform.webviewZoom?.(),
|
||||
() => dialog.active,
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
],
|
||||
() => schedule(300),
|
||||
),
|
||||
)
|
||||
createResizeObserver(() => surface, schedule.bind(null, 0))
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
portals.observe(document.body, { childList: true })
|
||||
onCleanup(() => portals.disconnect())
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
import FileTree from "@/session/files/file-tree"
|
||||
@@ -20,6 +21,8 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
const browserTabID = "session-side-panel-browser-tab"
|
||||
const browserTabPanelID = "session-side-panel-browser-tabpanel"
|
||||
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
@@ -33,6 +36,8 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
@@ -41,6 +46,8 @@ import {
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -65,6 +72,7 @@ export function SessionSidePanel(props: {
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -165,6 +173,7 @@ export function SessionSidePanel(props: {
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.opened,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -202,7 +211,7 @@ export function SessionSidePanel(props: {
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
const browserTab = createMemo(() => {
|
||||
const fileTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && file.pathFromTab(active)) return active
|
||||
@@ -212,11 +221,11 @@ export function SessionSidePanel(props: {
|
||||
// selects Review while the tab For replaces a preview trigger, which would
|
||||
// otherwise dispose the sidebar and reset scroll.
|
||||
const fileBrowserMounted = createMemo(() => {
|
||||
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
|
||||
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
@@ -298,7 +307,7 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
@@ -403,31 +412,78 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
<For each={props.browser.tabs()}>
|
||||
{(tab) => (
|
||||
<Tabs.Trigger
|
||||
value={sessionBrowserTab(tab.id)}
|
||||
id={`${browserTabID}-${tab.id}`}
|
||||
aria-controls={
|
||||
activeTab() === sessionBrowserTab(tab.id) ? browserTabPanelID : undefined
|
||||
}
|
||||
onMiddleClick={() => props.browser.close(tab.id)}
|
||||
closeButton={
|
||||
<Tooltip value={language.t("common.closeTab")} placement="bottom" gutter={10}>
|
||||
<Tabs.CloseButton
|
||||
onClick={() => props.browser.close(tab.id)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{tab.title || language.t("session.tab.browser")}
|
||||
</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus-small" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("session.tab.add")}
|
||||
// The tablist redirects focus entering it to the selected
|
||||
// tab, which counts as focus-outside and closes the menu.
|
||||
onPointerDown={(event: PointerEvent) => event.preventDefault()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
onSelect={openFileBrowser}
|
||||
shortcut={
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Show when={props.browser.available()}>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pe-3"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -469,6 +525,30 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
|
||||
}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
|
||||
inert={!isSessionBrowserTab(activeTab()) || undefined}
|
||||
>
|
||||
<Show when={props.browser.registration()} keyed>
|
||||
{(registration) => (
|
||||
<SessionBrowserPane
|
||||
registration={registration}
|
||||
browser={props.browser}
|
||||
visible={isSessionBrowserTab(activeTab())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
<div
|
||||
id={fileBrowserTabPanelID}
|
||||
@@ -479,11 +559,11 @@ export function SessionSidePanel(props: {
|
||||
inert={!fileBrowserVisible() || undefined}
|
||||
>
|
||||
<SessionFileBrowserTab
|
||||
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={
|
||||
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
}
|
||||
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
@@ -233,4 +235,40 @@ describe("createSessionTabs", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps multiple browser tabs distinct from file tabs", () => {
|
||||
createRoot((dispose) => {
|
||||
const first = sessionBrowserTab("first")
|
||||
const second = sessionBrowserTab("second")
|
||||
const result = createSessionTabs({
|
||||
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
|
||||
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
})
|
||||
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe(second)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(second)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
} from "@/shell/state/session-tabs"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
|
||||
@@ -20,6 +25,7 @@ type TabsInput = {
|
||||
review?: Accessor<boolean>
|
||||
hasReview?: Accessor<boolean>
|
||||
fileBrowser?: Accessor<boolean>
|
||||
browser?: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
@@ -30,6 +36,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||
const browser = input.browser ?? (() => false)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openFileOpen = createMemo(
|
||||
() =>
|
||||
@@ -43,7 +50,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (tab === "context" || tab === "review" || isSessionBrowserTab(tab)) return []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
@@ -61,6 +68,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
@@ -70,15 +78,16 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
if (review() && hasReview()) return "review"
|
||||
return "empty"
|
||||
})
|
||||
const activeFileTab = createMemo(() => {
|
||||
const activeFileTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
const closableTab = createMemo(() => {
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -144,7 +145,11 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -168,6 +173,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -43,6 +44,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const messagesReady = timeline.ready
|
||||
@@ -354,7 +356,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Ghostty, Terminal } from "ghostty-web"
|
||||
|
||||
describe("terminal application mouse reporting", () => {
|
||||
let container: HTMLDivElement
|
||||
let terminal: Terminal
|
||||
let canvas: HTMLCanvasElement
|
||||
let data: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() })
|
||||
terminal.open(container)
|
||||
terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h")
|
||||
canvas = container.querySelector("canvas")!
|
||||
data = []
|
||||
terminal.onData((value) => data.push(value))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
terminal.dispose()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
const wheel = (deltaX: number, deltaY: number) => {
|
||||
const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })
|
||||
Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } })
|
||||
canvas.dispatchEvent(event)
|
||||
}
|
||||
|
||||
test("reports wheel input with its terminal coordinates instead of arrow keys", () => {
|
||||
wheel(0, -100)
|
||||
|
||||
expect(data).toEqual(["\x1b[<64;6;3M"])
|
||||
})
|
||||
|
||||
test("does not discard small trackpad deltas", () => {
|
||||
wheel(0, -1)
|
||||
|
||||
expect(data).toEqual(["\x1b[<64;6;3M"])
|
||||
})
|
||||
|
||||
test("reports horizontal scrolling", () => {
|
||||
wheel(100, 0)
|
||||
|
||||
expect(data).toEqual(["\x1b[<67;6;3M"])
|
||||
})
|
||||
|
||||
test("distinguishes pointer movement from a left-button drag", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<35;6;3M"])
|
||||
})
|
||||
|
||||
test("encodes Alt-modified clicks", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<8;6;3M"])
|
||||
})
|
||||
|
||||
test("leaves application clicks available for process selection", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"])
|
||||
})
|
||||
|
||||
test("preserves Shift as the terminal selection override", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/p
|
||||
import { createRoot } from "solid-js"
|
||||
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
|
||||
const messages = [
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
@@ -63,6 +64,7 @@ describe("visibleTimelineMessages", () => {
|
||||
reasoningMode: () => "compact",
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
timelineDetail: () => timelinePresets[2].value,
|
||||
pendingUserMessageIDs: () => new Set([steer.id]),
|
||||
})
|
||||
expect(projection.activeMessageID()).toBe("msg_1")
|
||||
|
||||
@@ -21,6 +21,7 @@ import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessage
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { getSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
|
||||
@@ -101,12 +102,32 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
})
|
||||
})
|
||||
const showHeader = createMemo(() => !!input.session.identity.sessionID())
|
||||
const timelineDetail = createMemo(() => {
|
||||
const detail = settings.general.timelineDetail()
|
||||
return {
|
||||
shell: { ...detail.shell },
|
||||
edit: { ...detail.edit },
|
||||
thinking: { ...detail.thinking },
|
||||
subagents: { ...detail.subagents },
|
||||
notices: { ...detail.notices },
|
||||
tools: { ...detail.tools },
|
||||
}
|
||||
})
|
||||
const reasoningMode = (): ReasoningMode =>
|
||||
timelineDetail().thinking.placement === "hidden"
|
||||
? "hidden"
|
||||
: timelineDetail().thinking.details === "expanded"
|
||||
? "full"
|
||||
: "compact"
|
||||
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
|
||||
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: settings.general.editToolPartsExpanded,
|
||||
reasoningMode,
|
||||
shellToolDefaultOpen: shellToolPartsExpanded,
|
||||
editToolDefaultOpen: editToolPartsExpanded,
|
||||
timelineDetail,
|
||||
pendingUserMessageIDs,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
@@ -235,9 +256,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
childTitle,
|
||||
showHeader,
|
||||
projection,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
timelineDetail,
|
||||
reasoningMode,
|
||||
shellToolPartsExpanded,
|
||||
editToolPartsExpanded,
|
||||
},
|
||||
pending: {
|
||||
rename: () => pending.rename,
|
||||
|
||||
@@ -420,6 +420,7 @@ function MessageTimelineView(
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
|
||||
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
|
||||
projection,
|
||||
showHeader,
|
||||
pinned,
|
||||
@@ -527,6 +528,7 @@ function MessageTimelineView(
|
||||
reasoningMode: props.data.reasoningMode,
|
||||
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: props.data.editToolPartsExpanded,
|
||||
timelineDetail: props.data.timelineDetail,
|
||||
disclosure: virtualized.disclosure,
|
||||
centered: () => props.centered,
|
||||
padding: turnPadding,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
@@ -33,7 +33,15 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
|
||||
const endEpsilon = 0.5
|
||||
const upwardKeys = new Set(["up", "page-up", "home"])
|
||||
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
|
||||
const cache = new Map<
|
||||
string,
|
||||
{
|
||||
measurements: VirtualItem[]
|
||||
toolOpen: Record<string, boolean | undefined>
|
||||
patchGroupKeys: Map<string, string>
|
||||
presentationKey?: string
|
||||
}
|
||||
>()
|
||||
|
||||
type Projection = Pick<
|
||||
ReturnType<typeof createTimelineProjection>,
|
||||
@@ -42,6 +50,7 @@ type Projection = Pick<
|
||||
|
||||
type Input = {
|
||||
sessionKey: Accessor<string>
|
||||
presentationKey?: Accessor<string>
|
||||
projection: Projection
|
||||
showHeader: Accessor<boolean>
|
||||
/** True while the timeline follows the newest content. Drives every anchoring decision. */
|
||||
@@ -77,11 +86,20 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const entry = cache.get(ownerSessionKey)
|
||||
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => input.presentationKey?.(),
|
||||
() => setToolOpen(reconcile({})),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
@@ -189,7 +207,8 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return topOffset()
|
||||
// Empty projections still need the bottom spacer for running status.
|
||||
return rows().length > 0 ? topOffset() : 0
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
@@ -540,15 +559,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}}
|
||||
>
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
|
||||
<Show when={rows().length > 0}>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
@@ -557,7 +574,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
onCleanup(() => {
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
cache.set(ownerSessionKey, {
|
||||
measurements: virtualizer.takeSnapshot(),
|
||||
toolOpen: { ...toolOpen },
|
||||
patchGroupKeys,
|
||||
presentationKey: input.presentationKey?.(),
|
||||
})
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
@@ -569,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
return {
|
||||
disclosure: {
|
||||
patchGroupKeys,
|
||||
value: (key: string) => toolOpen[key],
|
||||
set: (key: string, open: boolean) => setToolOpen(key, open),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { TimelineDetailControl } from "@/settings/timeline-detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
@@ -184,34 +184,6 @@ const FollowUpBehaviorSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningModeSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
|
||||
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
|
||||
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
|
||||
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningMode.title")}
|
||||
description={language.t("settings.general.row.reasoningMode.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-reasoning-mode"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.reasoningMode())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -360,8 +332,6 @@ export const SettingsGeneral: Component<{
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<ReasoningModeSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("session.review.wrapLines")}
|
||||
description={language.t("settings.general.row.mobileDiffWrap.description")}
|
||||
@@ -378,30 +348,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
@@ -438,6 +384,20 @@ export const SettingsGeneral: Component<{
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={(checked) => settings.general.setExperimentalBrowser(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
@@ -585,6 +545,18 @@ export const SettingsGeneral: Component<{
|
||||
<div class="settings-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
|
||||
<h3 class="settings-section-title">{language.t("settings.timeline.title")}</h3>
|
||||
<SettingsList>
|
||||
<div class="py-5">
|
||||
<TimelineDetailControl
|
||||
value={settings.general.timelineDetail()}
|
||||
onChange={settings.general.setTimelineDetail}
|
||||
/>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</section>
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import {
|
||||
settingsSchema,
|
||||
@@ -16,54 +17,19 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
|
||||
describe("settings reasoning mode migration", () => {
|
||||
test.each([
|
||||
[true, "full"],
|
||||
[false, "compact"],
|
||||
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
|
||||
const settings = decode(value)
|
||||
expect(settings.general.reasoningMode).toBe(reasoningMode)
|
||||
expect(settings.general.showTerminal).toBe(true)
|
||||
describe("settings timeline detail migration", () => {
|
||||
test("migrates saved switches and round trips the current settings", () => {
|
||||
const settings = decode({
|
||||
general: { shellToolPartsExpanded: true, editToolPartsExpanded: false, showReasoningSummaries: true },
|
||||
appearance: { fontSize: 16 },
|
||||
})
|
||||
expect(settings.general.timelineDetail).toEqual({
|
||||
...timelinePresets[2].value,
|
||||
shell: { placement: "separate", details: "expanded" },
|
||||
thinking: { placement: "separate", details: "expanded" },
|
||||
})
|
||||
expect(settings.appearance.fontSize).toBe(16)
|
||||
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
|
||||
expect(value.general).not.toHaveProperty("reasoningMode")
|
||||
})
|
||||
|
||||
test.each(["hidden", "compact", "full"])(
|
||||
"preserves an explicit %s mode over either legacy value",
|
||||
(reasoningMode) => {
|
||||
;[true, false].forEach((showReasoningSummaries) => {
|
||||
const value = { general: { reasoningMode, showReasoningSummaries } }
|
||||
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
|
||||
"defaults invalid or absent legacy settings: %j",
|
||||
(general) => {
|
||||
expect(decode({ general }).general.reasoningMode).toBe("compact")
|
||||
},
|
||||
)
|
||||
|
||||
test("migrates an undefined current mode but defaults an invalid current mode", () => {
|
||||
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
|
||||
"full",
|
||||
)
|
||||
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
|
||||
"compact",
|
||||
)
|
||||
})
|
||||
|
||||
test("encodes only the current format and round trips migrated settings", () => {
|
||||
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
|
||||
const encoded = encode(settings)
|
||||
expect(encoded).toEqual(settings)
|
||||
expect(encoded).not.toHaveProperty("obsolete")
|
||||
expect(encoded).not.toHaveProperty("general.obsolete")
|
||||
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
|
||||
expect(decode(encoded)).toEqual(settings)
|
||||
expect(decode(encode(settings))).toEqual(settings)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,13 +37,16 @@ describe("settings schema", () => {
|
||||
test("uses the supplied initial values independently of the current schema", () => {
|
||||
const initial = {
|
||||
...defaultSettings,
|
||||
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
|
||||
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
|
||||
appearance: { ...defaultSettings.appearance, fontSize: 20 },
|
||||
}
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
|
||||
placement: "separate",
|
||||
details: "expanded",
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
@@ -92,14 +61,13 @@ describe("settings schema", () => {
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
reasoningMode: "compact",
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -144,7 +112,7 @@ describe("settings schema", () => {
|
||||
showTerminal: true,
|
||||
autoSave: false,
|
||||
releaseNotes: true,
|
||||
reasoningMode: "compact",
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
followUpBehavior: "steer",
|
||||
})
|
||||
expect(settings.appearance).toEqual({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { reconcile } from "solid-js/store"
|
||||
import { reconcile, unwrap } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
@@ -68,7 +68,10 @@ export function terminalFontFamily(font: string | undefined) {
|
||||
return stack(font, terminalBase)
|
||||
}
|
||||
|
||||
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
|
||||
const placementSchema = Schema.Literals(["separate", "grouped", "hidden"])
|
||||
const detailsSchema = Schema.Literals(["collapsed", "expanded"])
|
||||
const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema })
|
||||
const placementOnlySchema = Persistence.struct({ placement: placementSchema })
|
||||
|
||||
const generalSchema = Persistence.struct({
|
||||
autoSave: Schema.Boolean,
|
||||
@@ -79,14 +82,20 @@ const generalSchema = Persistence.struct({
|
||||
showStatus: Schema.Boolean,
|
||||
showProjectIcon: Schema.Boolean,
|
||||
showTerminal: Schema.Boolean,
|
||||
reasoningMode: reasoningModeSchema,
|
||||
shellToolPartsExpanded: Schema.Boolean,
|
||||
editToolPartsExpanded: Schema.Boolean,
|
||||
timelineDetail: Persistence.struct({
|
||||
shell: activitySchema,
|
||||
edit: activitySchema,
|
||||
thinking: activitySchema,
|
||||
subagents: placementOnlySchema,
|
||||
notices: placementOnlySchema,
|
||||
tools: placementOnlySchema,
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
|
||||
mobileDiffWrap: Schema.Boolean,
|
||||
terminalPlacement: Schema.Literals(["side", "bottom"]),
|
||||
followUpBehavior: Schema.Literals(["queue", "steer"]),
|
||||
experimentalBrowser: Schema.Boolean,
|
||||
})
|
||||
|
||||
const appearanceSchema = Persistence.struct({
|
||||
@@ -134,25 +143,91 @@ export const settingsSchema = Persistence.struct({
|
||||
sounds: soundsSchema,
|
||||
})
|
||||
|
||||
function storedTimelineCategory(category: TimelineCategory) {
|
||||
return Persistence.optional(
|
||||
Schema.Union([
|
||||
Schema.Struct({
|
||||
placement: Persistence.optional(placementSchema),
|
||||
details: Persistence.optional(detailsSchema),
|
||||
}),
|
||||
Schema.Literals(["expanded", "collapsed", "hidden", "visible"]),
|
||||
]).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (typeof value !== "string") return value
|
||||
return {
|
||||
placement:
|
||||
value === "hidden"
|
||||
? "hidden"
|
||||
: category === "subagents"
|
||||
? "separate"
|
||||
: category === "tools"
|
||||
? "grouped"
|
||||
: value === "expanded"
|
||||
? "separate"
|
||||
: value === "collapsed"
|
||||
? "grouped"
|
||||
: undefined,
|
||||
details: value === "expanded" ? "expanded" : "collapsed",
|
||||
}
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) {
|
||||
if (value === undefined || value === null) return
|
||||
const expanded = value === true || value === "full"
|
||||
return {
|
||||
placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped",
|
||||
details: expanded ? "expanded" : "collapsed",
|
||||
} as const
|
||||
}
|
||||
|
||||
export const settingsPersistence = Persistence.migrate(
|
||||
settingsSchema,
|
||||
Schema.Struct({
|
||||
general: Persistence.optional(
|
||||
Schema.Struct({
|
||||
reasoningMode: Schema.optional(Schema.Unknown),
|
||||
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
|
||||
timelineDetail: Schema.optional(
|
||||
Schema.NullOr(
|
||||
Schema.Struct({
|
||||
shell: storedTimelineCategory("shell"),
|
||||
edit: storedTimelineCategory("edit"),
|
||||
thinking: storedTimelineCategory("thinking"),
|
||||
subagents: storedTimelineCategory("subagents"),
|
||||
notices: storedTimelineCategory("notices"),
|
||||
tools: storedTimelineCategory("tools"),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))),
|
||||
reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe(
|
||||
Schema.catchDecoding(() => Effect.succeed(Option.some(null))),
|
||||
),
|
||||
showReasoningSummaries: Persistence.optional(Schema.Boolean),
|
||||
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
|
||||
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
|
||||
return value
|
||||
const general = value.general
|
||||
if (!general || general.timelineDetail !== undefined) return value
|
||||
return {
|
||||
...value,
|
||||
general: {
|
||||
...value.general,
|
||||
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
|
||||
...general,
|
||||
timelineDetail: {
|
||||
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
|
||||
edit: legacyTimelineActivity(general.editToolPartsExpanded),
|
||||
thinking: legacyTimelineActivity(
|
||||
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -171,14 +246,13 @@ export const defaultSettings: Settings = {
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
reasoningMode: "compact",
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: true,
|
||||
},
|
||||
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
|
||||
keybinds: {},
|
||||
@@ -256,23 +330,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
|
||||
setReasoningMode(value: ReasoningMode) {
|
||||
setStore("general", "reasoningMode", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
|
||||
setTimelineDetail(value: TimelineDetail) {
|
||||
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
|
||||
},
|
||||
showCustomAgents,
|
||||
setShowCustomAgents(value: boolean) {
|
||||
@@ -300,6 +360,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowUpBehavior(value: FollowUpBehavior) {
|
||||
setStore("general", "followUpBehavior", value)
|
||||
},
|
||||
experimentalBrowser: withFallback(
|
||||
() => store.general?.experimentalBrowser,
|
||||
defaultSettings.general.experimentalBrowser,
|
||||
),
|
||||
setExperimentalBrowser(value: boolean) {
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -7,13 +7,17 @@
|
||||
|
||||
.settings-screen {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
width: calc(100% - 16px);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin-inline: 8px;
|
||||
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--v2-background-bg-deep);
|
||||
border-radius: 10px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
outline: none;
|
||||
container: settings-screen / inline-size;
|
||||
}
|
||||
@@ -27,7 +31,7 @@
|
||||
@media (max-width: 767px) {
|
||||
.settings-screen {
|
||||
--settings-mobile-inner-inset: 8px;
|
||||
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
|
||||
margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +55,7 @@
|
||||
|
||||
.settings-screen .settings-tab-header {
|
||||
padding: 48px 0 32px;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-body {
|
||||
@@ -349,7 +353,7 @@
|
||||
gap: 16px;
|
||||
padding: 8px var(--settings-mobile-inner-inset, 16px);
|
||||
border-bottom: 0.5px solid var(--v2-border-border-muted);
|
||||
background: var(--v2-background-bg-deep);
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.settings-mobile-nav::after {
|
||||
@@ -358,7 +362,7 @@
|
||||
inset-inline: 0;
|
||||
inset-block-start: 100%;
|
||||
height: 1px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
background: var(--v2-background-bg-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
onCleanup,
|
||||
onMount,
|
||||
startTransition,
|
||||
} from "solid-js"
|
||||
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
@@ -54,9 +44,7 @@ const sections = [
|
||||
],
|
||||
] as const
|
||||
|
||||
export const SettingsScreen: Component<{
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
export const SettingsScreen: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
@@ -66,7 +54,6 @@ export const SettingsScreen: Component<{
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
@@ -75,10 +62,8 @@ export const SettingsScreen: Component<{
|
||||
})
|
||||
onCleanup(() => command.keybinds(true))
|
||||
|
||||
createEffect(() => setTab(props.defaultValue ?? "general"))
|
||||
|
||||
const server = createMemo(() => {
|
||||
const route = layout.route()
|
||||
const route = surface.route()
|
||||
switch (route.type) {
|
||||
case "draft": {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
@@ -101,7 +86,7 @@ export const SettingsScreen: Component<{
|
||||
const selected = global.settings.server.selected()
|
||||
const current = server()
|
||||
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
|
||||
const route = layout.route()
|
||||
const route = surface.route()
|
||||
if (route.type === "draft") {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
@@ -112,7 +97,7 @@ export const SettingsScreen: Component<{
|
||||
|
||||
const showProviders = () => {
|
||||
dialog.close()
|
||||
setTab("providers")
|
||||
surface.open("providers")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -130,8 +115,8 @@ export const SettingsScreen: Component<{
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
class="settings"
|
||||
>
|
||||
<div class="settings-mobile-nav">
|
||||
@@ -143,14 +128,18 @@ export const SettingsScreen: Component<{
|
||||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>
|
||||
{language.t(
|
||||
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
|
||||
sections.flat().find((section) => section.value === surface.tab())?.label ??
|
||||
"settings.tab.preferences",
|
||||
)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
|
||||
<Menu.RadioGroup
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
>
|
||||
<For each={sections}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
|
||||
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
|
||||
name: "SettingsSurface",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const location = useLocation()
|
||||
const [store, setStore] = createStore({ open: false, tab: "general" })
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const command = useCommand()
|
||||
const location = useLocation<{
|
||||
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
|
||||
}>()
|
||||
const open = () => layout.route().type === "settings"
|
||||
const source = () => location.state?.settings?.route ?? { type: "home" as const }
|
||||
let focus: HTMLElement | undefined
|
||||
|
||||
const close = () => {
|
||||
if (!store.open) return
|
||||
setStore("open", false)
|
||||
if (focus?.isConnected) focus.focus({ preventScroll: true })
|
||||
focus = undefined
|
||||
}
|
||||
|
||||
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(value) => {
|
||||
if (value) return
|
||||
if (focus?.isConnected) focus.focus({ preventScroll: true })
|
||||
focus = undefined
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
store,
|
||||
active: open,
|
||||
route: source,
|
||||
tab: () => location.state?.settings?.tab ?? "general",
|
||||
open(tab = "general") {
|
||||
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
|
||||
setStore({ open: true, tab })
|
||||
const route = layout.route()
|
||||
if (route.type !== "settings") {
|
||||
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
|
||||
}
|
||||
navigate("/settings", {
|
||||
replace: open(),
|
||||
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
|
||||
})
|
||||
},
|
||||
close() {
|
||||
if (open()) command.trigger("common.goBack")
|
||||
},
|
||||
close,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
[data-component="timeline-detail-control"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
container: timeline-detail / inline-size;
|
||||
|
||||
[data-slot="timeline-detail-heading"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-current"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-scale"] {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-track"] {
|
||||
position: absolute;
|
||||
inset-inline: 8px;
|
||||
top: 12px;
|
||||
height: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 2px;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -6px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
|
||||
width: fit-content;
|
||||
height: 28px;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
font-size: inherit;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: inherit;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:focus-visible [data-slot="collapsible-arrow"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:dir(rtl):not([aria-expanded="true"]) [data-slot="collapsible-arrow-icon"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-explainer"] {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-field-label"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"],
|
||||
[data-slot="timeline-detail-category"] {
|
||||
display: grid;
|
||||
grid-template-columns: var(--timeline-detail-columns);
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
line-height: var(--line-height-compact);
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"] {
|
||||
min-height: 28px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
span:not(:first-child) {
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-category"] {
|
||||
min-height: 40px;
|
||||
padding-block: 8px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"],
|
||||
[data-slot="timeline-detail-placement"],
|
||||
[data-slot="timeline-detail-expansion"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"][data-field] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="select-v2"][data-appearance="inline"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container timeline-detail (max-width: 250px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
padding-block: 12px;
|
||||
|
||||
> span {
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
padding-inline-start: 8px;
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container timeline-detail (max-width: 310px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { For, Show, createMemo, createUniqueId } from "solid-js"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import {
|
||||
timelineCategories,
|
||||
timelinePreset,
|
||||
timelinePresets,
|
||||
type TimelineDetail,
|
||||
type TimelineExpansion,
|
||||
type TimelinePlacement,
|
||||
} from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./timeline-detail.css"
|
||||
|
||||
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
|
||||
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
|
||||
|
||||
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
|
||||
const language = useLanguage()
|
||||
const id = createUniqueId()
|
||||
const preset = createMemo(() => timelinePreset(props.value))
|
||||
const position = () => {
|
||||
const current = preset()
|
||||
return current ? timelinePresets.indexOf(current) : 2
|
||||
}
|
||||
const label = () => {
|
||||
const current = preset()
|
||||
return current ? language.t(`settings.timeline.preset.${current.id}`) : language.t("settings.timeline.custom")
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="timeline-detail-control">
|
||||
<div data-slot="timeline-detail-heading">
|
||||
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
|
||||
<span data-slot="timeline-detail-current" aria-live="polite">
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
<p id={`${id}-description`} class="sr-only">
|
||||
{language.t("settings.timeline.description")}
|
||||
</p>
|
||||
<div data-slot="timeline-detail-scale">
|
||||
<div data-slot="timeline-detail-track" aria-hidden="true">
|
||||
<For each={timelinePresets}>{() => <span />}</For>
|
||||
</div>
|
||||
<input
|
||||
id={`${id}-slider`}
|
||||
data-action="settings-timeline-detail"
|
||||
type="range"
|
||||
min="0"
|
||||
max={timelinePresets.length - 1}
|
||||
step="1"
|
||||
value={position()}
|
||||
aria-valuetext={label()}
|
||||
aria-describedby={`${id}-description ${id}-preset-description`}
|
||||
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
|
||||
/>
|
||||
</div>
|
||||
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
|
||||
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
|
||||
<Collapsible.Trigger>
|
||||
<span>{language.t("settings.timeline.advanced")}</span>
|
||||
<Collapsible.Arrow />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div
|
||||
data-slot="timeline-detail-categories"
|
||||
role="group"
|
||||
aria-label={language.t("settings.timeline.advanced.description")}
|
||||
>
|
||||
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
|
||||
<div data-slot="timeline-detail-columns">
|
||||
<span>{language.t("settings.timeline.activity")}</span>
|
||||
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
|
||||
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
|
||||
</div>
|
||||
<For each={timelineCategories}>
|
||||
{(category) => (
|
||||
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
|
||||
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
|
||||
<div data-slot="timeline-detail-placement">
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.placement.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="placement"
|
||||
aria-labelledby={`${id}-${category} ${id}-placement`}
|
||||
options={placements}
|
||||
current={props.value[category].placement}
|
||||
label={(value) => language.t(`settings.timeline.placement.${value}`)}
|
||||
onSelect={(placement) =>
|
||||
placement &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="timeline-detail-expansion">
|
||||
{category === "shell" || category === "edit" || category === "thinking" ? (
|
||||
<Show when={props.value[category].placement !== "hidden"}>
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.expansion.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="details"
|
||||
aria-labelledby={`${id}-${category} ${id}-expansion`}
|
||||
options={expansions}
|
||||
current={props.value[category].details}
|
||||
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
|
||||
onSelect={(details) =>
|
||||
details &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { requireServerKey } from "./session"
|
||||
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
|
||||
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
@@ -20,6 +21,7 @@ const TargetSessionRouteContent = lazy(() =>
|
||||
export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -29,6 +31,7 @@ export function AppRoutes() {
|
||||
return (
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
@@ -86,24 +85,14 @@ export default function Layout(props: ParentProps) {
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
|
||||
style={{
|
||||
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
|
||||
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
inert={settings.store.open}
|
||||
aria-hidden={settings.store.open}
|
||||
>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
<Show when={settings.store.open}>
|
||||
<Suspense>
|
||||
<SettingsScreen defaultValue={settings.store.tab} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
|
||||
@@ -3,9 +3,13 @@ import { createRoot, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { initialLayout, layoutPersistence, layoutSchema } from "./layout"
|
||||
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
|
||||
test("settings has its own layout route", () => {
|
||||
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
@@ -66,6 +66,7 @@ export type TabPanes = {
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "settings" }
|
||||
| { type: "draft"; draftID: string }
|
||||
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||
|
||||
@@ -104,6 +105,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
if (parts[0] === "settings") return { type: "settings" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
|
||||
@@ -8,56 +8,75 @@ function history(): TitlebarHistory {
|
||||
describe("titlebar history", () => {
|
||||
test("append and trim keeps max bounded", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
state = applyPath(state, { url: "/" }, 3)
|
||||
state = applyPath(state, { url: "/a" }, 3)
|
||||
state = applyPath(state, { url: "/b" }, 3)
|
||||
state = applyPath(state, { url: "/c" }, 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.stack.length).toBe(3)
|
||||
expect(state.index).toBe(2)
|
||||
})
|
||||
|
||||
test("back and forward indexes stay correct after trimming", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
state = applyPath(state, { url: "/" }, 3)
|
||||
state = applyPath(state, { url: "/a" }, 3)
|
||||
state = applyPath(state, { url: "/b" }, 3)
|
||||
state = applyPath(state, { url: "/c" }, 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.index).toBe(2)
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/b")
|
||||
expect(back?.to.url).toBe("/b")
|
||||
expect(back?.state.index).toBe(1)
|
||||
|
||||
const afterBack = applyPath(back!.state, back!.to, 3)
|
||||
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterBack.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterBack.index).toBe(1)
|
||||
|
||||
const forward = forwardPath(afterBack)
|
||||
expect(forward?.to).toBe("/c")
|
||||
expect(forward?.to.url).toBe("/c")
|
||||
expect(forward?.state.index).toBe(2)
|
||||
|
||||
const afterForward = applyPath(forward!.state, forward!.to, 3)
|
||||
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterForward.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterForward.index).toBe(2)
|
||||
})
|
||||
|
||||
test("action-driven navigation does not push duplicate history entries", () => {
|
||||
const state: TitlebarHistory = {
|
||||
stack: ["/", "/a", "/b"],
|
||||
stack: [{ url: "/" }, { url: "/a" }, { url: "/b" }],
|
||||
index: 2,
|
||||
action: undefined,
|
||||
}
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/a")
|
||||
expect(back?.to.url).toBe("/a")
|
||||
|
||||
const next = applyPath(back!.state, back!.to, 10)
|
||||
expect(next.stack).toEqual(["/", "/a", "/b"])
|
||||
expect(next.stack.map((entry) => entry.url)).toEqual(["/", "/a", "/b"])
|
||||
expect(next.index).toBe(1)
|
||||
expect(next.action).toBeUndefined()
|
||||
})
|
||||
|
||||
test("settings visits retain their own route state", () => {
|
||||
const first = { url: "/settings", state: { settings: { type: "draft", draftID: "a" } } }
|
||||
const second = { url: "/settings", state: { settings: { type: "draft", draftID: "b" } } }
|
||||
const state = applyPath(applyPath(applyPath(history(), first), { url: "/b" }), second)
|
||||
const back = backPath(state)!
|
||||
const previous = backPath(applyPath(back.state, back.to))!
|
||||
expect(previous.to).toEqual(first)
|
||||
expect(forwardPath(applyPath(back.state, back.to))?.to).toEqual(second)
|
||||
})
|
||||
|
||||
test("replacing settings state does not add a back navigation", () => {
|
||||
const initial = applyPath(history(), { url: "/settings", state: { tab: "general" } })
|
||||
const updated = applyPath(initial, { url: "/settings", state: { tab: "models" } })
|
||||
expect(updated.stack).toHaveLength(2)
|
||||
const back = backPath(updated)!
|
||||
expect(back.to.url).toBe("/")
|
||||
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,32 @@ export const MAX_TITLEBAR_HISTORY = 100
|
||||
|
||||
export type TitlebarAction = "back" | "forward" | undefined
|
||||
|
||||
export type HistoryLocation = { url: string; state?: unknown }
|
||||
|
||||
export type TitlebarHistory = {
|
||||
stack: string[]
|
||||
stack: HistoryLocation[]
|
||||
index: number
|
||||
action: TitlebarAction
|
||||
}
|
||||
|
||||
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
export function applyPath(
|
||||
state: TitlebarHistory,
|
||||
current: HistoryLocation,
|
||||
max = MAX_TITLEBAR_HISTORY,
|
||||
): TitlebarHistory {
|
||||
if (!state.stack.length) {
|
||||
const stack = current === "/" ? ["/"] : ["/", current]
|
||||
const stack = current.url === "/" ? [current] : [{ url: "/" }, current]
|
||||
return { stack, index: stack.length - 1, action: undefined }
|
||||
}
|
||||
|
||||
const active = state.stack[state.index]
|
||||
if (current === active) {
|
||||
if (!state.action) return state
|
||||
return { ...state, action: undefined }
|
||||
if (current.url === active.url) {
|
||||
if (!state.action && current.state === active.state) return state
|
||||
return {
|
||||
...state,
|
||||
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
|
||||
action: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (state.action) return { ...state, action: undefined }
|
||||
@@ -25,13 +35,13 @@ export function applyPath(state: TitlebarHistory, current: string, max = MAX_TIT
|
||||
return pushPath(state, current, max)
|
||||
}
|
||||
|
||||
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(path)
|
||||
const next = trimHistory(stack, stack.length - 1, max)
|
||||
return { ...state, ...next, action: undefined }
|
||||
}
|
||||
|
||||
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
|
||||
export function trimHistory(stack: HistoryLocation[], index: number, max = MAX_TITLEBAR_HISTORY) {
|
||||
if (stack.length <= max) return { stack, index }
|
||||
const cut = stack.length - max
|
||||
return {
|
||||
|
||||
@@ -432,7 +432,20 @@ export function DraftTabItem(props: {
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Icon name="edit" />
|
||||
<svg
|
||||
class="text-v2-icon-icon-muted group-data-[active='true']:text-v2-icon-icon-base"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9.00002 13.5H14M2.60419 10.9167V13.3958H5.08335L13.3959 5.08333L10.9167 2.60416L2.60419 10.9167Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
|
||||
@@ -377,7 +377,7 @@ export function TitlebarTabStrip(props: {
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
title={language.t("command.session.new")}
|
||||
title={language.t("session.tab.session")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -13,7 +13,7 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { WindowsAppMenu } from "./windows-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./history"
|
||||
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
|
||||
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -75,7 +75,7 @@ export function Titlebar(props: {
|
||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||
|
||||
const [history, setHistory] = createStore({
|
||||
stack: [] as string[],
|
||||
stack: [] as HistoryLocation[],
|
||||
index: 0,
|
||||
action: undefined as "back" | "forward" | undefined,
|
||||
})
|
||||
@@ -83,7 +83,7 @@ export function Titlebar(props: {
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
|
||||
createEffect(() => {
|
||||
const current = path()
|
||||
const current = { url: path(), state: location.state }
|
||||
|
||||
untrack(() => {
|
||||
const next = applyPath(history, current)
|
||||
@@ -113,14 +113,14 @@ export function Titlebar(props: {
|
||||
const next = backPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
navigate(next.to.url, { state: unwrap(next.to.state) })
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
const next = forwardPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
navigate(next.to.url, { state: unwrap(next.to.state) })
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
@@ -297,6 +297,7 @@ export function Titlebar(props: {
|
||||
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
case "settings":
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
const conn =
|
||||
@@ -432,8 +433,8 @@ export function Titlebar(props: {
|
||||
"md:pl-4": !macTrafficLights(),
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && !props.verticalTabs}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -624,10 +625,22 @@ export function Titlebar(props: {
|
||||
>
|
||||
<Show when={macVerticalTabs()}>
|
||||
<div
|
||||
class="relative w-full shrink-0"
|
||||
class="relative mb-2 w-full shrink-0"
|
||||
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
|
||||
data-tauri-drag-region
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
class="absolute -top-0.5 bottom-0.5 flex items-center"
|
||||
style={{
|
||||
// Native traffic lights stay on the physical left; subtract the sidebar padding.
|
||||
left: macTrafficLights()
|
||||
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
@@ -637,10 +650,10 @@ export function Titlebar(props: {
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<Icon name="edit" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
@@ -657,13 +670,14 @@ export function Titlebar(props: {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
|
||||
<div class="absolute bottom-0 left-0 flex h-9 items-center">
|
||||
<div
|
||||
data-slot="vertical-tabs-footer"
|
||||
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<TitlebarRightMount />
|
||||
<Show when={!macVerticalTabs() && !windows()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex h-9 items-center">
|
||||
<TitlebarRightMount />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -739,13 +753,19 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
|
||||
const platform = usePlatform()
|
||||
const style = () => ({
|
||||
height: props.height,
|
||||
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
@@ -759,7 +779,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
return (
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
<div
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Path } from "@/runtime/server/types"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
@@ -26,16 +26,19 @@ import {
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
listPickerDirectory,
|
||||
pickerRelativePath,
|
||||
pickerAbsolutePath,
|
||||
} from "./domain"
|
||||
import "./dialog.css"
|
||||
import { Divider } from "@opencode-ai/ui/divider"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
mode?: "directory" | "file"
|
||||
start?: string
|
||||
}
|
||||
@@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
let navigation = 0
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
() => (props.location ? undefined : true),
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath()
|
||||
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
|
||||
})
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath()?.home ||
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
@@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
const base = location()?.directory
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
location: location(),
|
||||
query,
|
||||
type: "file",
|
||||
limit: 20,
|
||||
})
|
||||
@@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const current = location()
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
@@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
if (!path || root()) return
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
listPickerDirectory,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
@@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
||||
})
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
test("resolves directory autocomplete from the browser root without changing location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
find: (input: unknown) => {
|
||||
calls.push(input)
|
||||
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
|
||||
},
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
|
||||
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
base = "/repo/src"
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "components", type: "directory", limit: 50 },
|
||||
{ location, query: "src/components", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: async (input: { path?: string }) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location,
|
||||
data:
|
||||
input.path === "/repo"
|
||||
? [
|
||||
{ path: "./", type: "directory" },
|
||||
{ path: "../sibling/", type: "directory" },
|
||||
]
|
||||
: [{ path: "../sibling/src/", type: "directory" }],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "current", absolute: "/repo/current", type: "directory" },
|
||||
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
|
||||
])
|
||||
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
|
||||
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: "/repo" },
|
||||
{ location, path: "/repo/sibling" },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses listings for typed searches outside the current location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.reject(new Error("outside searches must not change location")),
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
expect(await search("sib")).toEqual(["/repo/sibling"])
|
||||
expect(calls).toEqual([{ location, path: "/repo" }])
|
||||
})
|
||||
|
||||
test("keeps literal tilde directory names in server listing and search results", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
|
||||
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "~", absolute: "/repo/~", type: "directory" },
|
||||
])
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
|
||||
expect(await search("nested")).toEqual(["/repo/~/nested"])
|
||||
})
|
||||
|
||||
test("discards stale typed results without changing the request location", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const pending = Promise.withResolvers<{
|
||||
location: typeof location
|
||||
data: Array<{ path: string; type: "directory" }>
|
||||
}>()
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: async (input: { query: string }) => {
|
||||
calls.push(input)
|
||||
if (input.query === "old") return pending.promise
|
||||
return { location, data: [{ path: "new/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
const stale = search("old")
|
||||
expect(await search("new")).toEqual(["/repo/new"])
|
||||
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
|
||||
expect(await stale).toEqual([])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "old", type: "directory", limit: 50 },
|
||||
{ location, query: "new", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("maps server-native drive and share paths without rebasing the location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async (input: { location: { directory: string }; path: string }) => {
|
||||
calls.push(input)
|
||||
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
|
||||
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
|
||||
])
|
||||
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
|
||||
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location: drive, path: "c:/repo" },
|
||||
{ location: share, path: "//server/share" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("")).toEqual(["/home/luke/projects"])
|
||||
})
|
||||
|
||||
test("lists the default directory when empty search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const calls: string[] = []
|
||||
const directories = Array.from({ length: 60 }, (_, index) => ({
|
||||
path: `project-${index}/`,
|
||||
@@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
calls.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [...directories, { path: "README.md", type: "file" }],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
const results = await search("")
|
||||
expect(results).toHaveLength(60)
|
||||
@@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
|
||||
})
|
||||
|
||||
test("matches the default directory listing when typed search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Documents/", type: "directory" },
|
||||
{ path: "Downloads/", type: "directory" },
|
||||
@@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("documents")).toEqual(["/home/luke/Documents"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const location = { directory: "/" }
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
@@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Users/", type: "directory" },
|
||||
{ path: "tmp/", type: "directory" },
|
||||
@@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
|
||||
|
||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||
expect(directories).toEqual(["/"])
|
||||
|
||||
@@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
|
||||
|
||||
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
||||
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
||||
return pickerAbsolutePath(value, current)
|
||||
}
|
||||
|
||||
export function pickerAbsolutePath(input: string, current: string) {
|
||||
const value = normalizePickerDrive(input)
|
||||
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
||||
return canonicalPickerPath(absolute)
|
||||
}
|
||||
@@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
||||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
export async function listPickerDirectory(
|
||||
sdk: ServerSDK,
|
||||
location: { directory: string; workspace?: string },
|
||||
directory: string,
|
||||
) {
|
||||
const result = await sdk.api.file.list({ location, path: directory })
|
||||
return result.data.map((entry) => {
|
||||
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
|
||||
return { name: getFilename(absolute), type: entry.type, absolute }
|
||||
})
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: {
|
||||
sdk: ServerSDK
|
||||
location: () => { directory: string; workspace?: string } | undefined
|
||||
base: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
@@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
}
|
||||
|
||||
const directories = async (directory: string) => {
|
||||
const key = trimPickerPath(directory)
|
||||
const location = args.location()
|
||||
if (!location) return []
|
||||
const key = JSON.stringify([location, trimPickerPath(directory)])
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
const request = listPickerDirectory(args.sdk, location, directory)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
.then((nodes) => nodes.filter((node) => node.type === "directory"))
|
||||
cache.set(key, request)
|
||||
return request
|
||||
}
|
||||
@@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const active = () => token === current
|
||||
const value = cleanPickerInput(filter)
|
||||
const input = scoped(value)
|
||||
if (!input) return [] as string[]
|
||||
const location = args.location()
|
||||
if (!input || !location) return [] as string[]
|
||||
const raw = normalizePickerDrive(value)
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
const relative = pickerRelativePath(location.directory, input.directory)
|
||||
const results =
|
||||
relative === undefined
|
||||
? []
|
||||
: await args.sdk.api.file
|
||||
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
|
||||
.then((result) =>
|
||||
result.data
|
||||
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
|
||||
.filter((path) => treePathWithin(input.directory, path)),
|
||||
)
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
if (results.length) {
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
return results.slice(0, 50)
|
||||
}
|
||||
const fallback = query
|
||||
? await match(input.directory, query, 50)
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy } from "solid-js"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
const DirectoryPickerDialog = lazy(() =>
|
||||
@@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
|
||||
@@ -141,7 +141,12 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
outfile: path.join(outdir, name, "bin", binary),
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
|
||||
execArgv: [
|
||||
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
|
||||
"--use-system-ca",
|
||||
"--no-warnings",
|
||||
"--",
|
||||
],
|
||||
windows: {},
|
||||
},
|
||||
define: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
import { Env } from "../../env"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
@@ -59,7 +59,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode-ai/util/observability"
|
||||
import { Updater } from "./services/updater"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
@@ -113,7 +113,7 @@ Effect.gen(function* () {
|
||||
Observability.layer({
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||
client: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
client: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
@@ -86,7 +86,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
|
||||
@@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
|
||||
|
||||
export type FileListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly path?: RelativePath | undefined
|
||||
readonly path?: string | undefined
|
||||
}
|
||||
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
|
||||
|
||||
@@ -1180,8 +1180,8 @@ export function createData(config: CreateDataInput) {
|
||||
}))
|
||||
break
|
||||
case "reference.updated":
|
||||
result.location.reference.invalidate()
|
||||
void result.location.reference.sync()
|
||||
result.location.reference.invalidate(location)
|
||||
void result.location.reference.sync(location)
|
||||
break
|
||||
case "integration.updated":
|
||||
result.location.integration.invalidate(location)
|
||||
|
||||
@@ -119,6 +119,28 @@ test("multiple consumers share one source and receive live native and RPC events
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("a slow consumer does not stall other subscribers", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const slow = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const fast = shared.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
const connected = [slow.next(), fast.next()]
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await Promise.all(connected)
|
||||
|
||||
events.connections[0].push({ type: "permission.asked", value: 1 })
|
||||
expect(await fast.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
|
||||
events.connections[0].push({ type: "session.updated", value: 2 })
|
||||
expect(await fast.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
|
||||
|
||||
expect(await slow.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
|
||||
expect(await slow.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
|
||||
await slow.return!()
|
||||
await fast.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("an idle consumer does not stall events for an active consumer", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
|
||||
@@ -409,6 +409,66 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes references for the location an update names", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: URL[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
requests.push(url)
|
||||
const directory = url.searchParams.get("location[directory]") ?? "/project"
|
||||
return Response.json({
|
||||
location: {
|
||||
directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project: { id: "project", directory, canonical: directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => "connected" },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const other = { directory: "/other", workspaceID: "workspace-other" }
|
||||
|
||||
try {
|
||||
await Promise.all([setup.data.location.reference.sync(), setup.data.location.reference.sync(other)])
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_reference.updated",
|
||||
created: 1,
|
||||
type: "reference.updated",
|
||||
location: other,
|
||||
data: {},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
await wait(() => requests.length === 1)
|
||||
expect([
|
||||
requests[0]!.pathname,
|
||||
requests[0]!.searchParams.get("location[directory]"),
|
||||
requests[0]!.searchParams.get("location[workspace]"),
|
||||
]).toEqual(["/api/reference", "/other", "workspace-other"])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -100,6 +100,7 @@ const layer = Layer.effect(
|
||||
editor.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
current.provider.id = providerID
|
||||
},
|
||||
remove: (providerID) => {
|
||||
editor.providers.delete(providerID)
|
||||
|
||||
@@ -25,7 +25,7 @@ export const Content = Schema.Struct({
|
||||
export type Content = typeof Content.Type
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
@@ -93,17 +93,18 @@ const baseLayer = Layer.effect(
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
// Navigation can leave the cwd without activating another Location.
|
||||
const directory = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
return yield* fs.readDirectoryEntries(directory).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
if (item.type !== "file" && item.type !== "directory") return []
|
||||
const absolute = path.join(target.absolute, item.name)
|
||||
const relative = path.relative(target.directory, absolute)
|
||||
const absolute = path.join(directory, item.name)
|
||||
const relative = path.relative(location.directory, absolute) || "."
|
||||
return [
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
|
||||
@@ -50,16 +50,14 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let requested = 0
|
||||
let stopped = false
|
||||
let active: { path: string; scope: Scope.Closeable } | undefined
|
||||
const reconcile = (ignore: readonly string[]) => {
|
||||
const request = ++requested
|
||||
return lock.withPermit(
|
||||
const reconcile = () =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (stopped || request !== requested) return
|
||||
if (stopped) return
|
||||
const resolved = yield* target
|
||||
if (stopped || request !== requested) return
|
||||
const ignore = policy.current()
|
||||
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
|
||||
if (active?.path === next) return
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
@@ -79,12 +77,10 @@ const layer = Layer.effect(
|
||||
)
|
||||
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
|
||||
)
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
stopped = true
|
||||
requested++
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
active = undefined
|
||||
}),
|
||||
@@ -93,7 +89,7 @@ const layer = Layer.effect(
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Plugin.awaitActivation
|
||||
yield* reconcile(policy.current())
|
||||
yield* reconcile()
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
|
||||
@@ -397,6 +397,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: editor.remove,
|
||||
list: editor.list,
|
||||
get: editor.get,
|
||||
})
|
||||
}),
|
||||
},
|
||||
@@ -407,6 +408,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
skill.transform((editor) => {
|
||||
callback({
|
||||
list: () => mutable(editor.list()),
|
||||
get: editor.get,
|
||||
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: editor.update,
|
||||
remove: editor.remove,
|
||||
|
||||
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode-ai/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
|
||||
@@ -32,6 +32,7 @@ type Editor = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
get(name: string): Source | undefined
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
@@ -98,6 +99,7 @@ const layer = Layer.effect(
|
||||
add: (name, source) => editor.sources.set(name, source),
|
||||
remove: (name) => editor.sources.delete(name),
|
||||
list: () => Array.from(editor.sources),
|
||||
get: (name) => editor.sources.get(name),
|
||||
}),
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { KV } from "./kv.js"
|
||||
|
||||
const Refresh = Schema.Struct({
|
||||
attemptedAt: Schema.Number,
|
||||
refreshedAt: Schema.optionalKey(Schema.Number),
|
||||
})
|
||||
const refreshInterval = Duration.toMillis(Duration.days(1))
|
||||
|
||||
@@ -161,7 +160,7 @@ const layer = Layer.effect(
|
||||
|
||||
if (status !== "cached") {
|
||||
// Record attempts before network work so failures obey the same refresh interval.
|
||||
yield* kv.set(key, { ...previous, attemptedAt: now })
|
||||
yield* kv.set(key, { attemptedAt: now })
|
||||
|
||||
if (status === "cloned") {
|
||||
yield* git.repo
|
||||
@@ -205,8 +204,6 @@ const layer = Layer.effect(
|
||||
.resetHard(existing, target ? `origin/${target}` : "HEAD")
|
||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
|
||||
}
|
||||
|
||||
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
|
||||
@@ -74,6 +74,7 @@ export type Data = {
|
||||
|
||||
export type Editor = {
|
||||
list: () => readonly Types.DeepMutable<Info>[]
|
||||
get: (id: string) => Types.DeepMutable<Info> | undefined
|
||||
add: (skill: Info) => void
|
||||
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: string) => void
|
||||
@@ -96,6 +97,7 @@ const layer = Layer.effect(
|
||||
initial: () => ({ skills: new Map() }),
|
||||
editor: (editor) => ({
|
||||
list: () => Array.from(editor.skills.values()),
|
||||
get: (id) => editor.skills.get(ID.make(id)),
|
||||
add: (skill) => {
|
||||
editor.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
|
||||
},
|
||||
|
||||
@@ -93,6 +93,32 @@ describe("Catalog", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider identity when updating new and existing providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("original")
|
||||
const renamed = Provider.ID.make("renamed")
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.id = renamed
|
||||
provider.name = "Created"
|
||||
})
|
||||
expect(editor.provider.get(providerID)?.provider.id).toBe(providerID)
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.id = renamed
|
||||
provider.name = "Updated"
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
|
||||
expect(yield* catalog.provider.get(renamed)).toBeUndefined()
|
||||
expect((yield* catalog.provider.all()).map((provider) => provider.id)).toEqual([providerID])
|
||||
|
||||
yield* catalog.reload()
|
||||
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -125,6 +126,7 @@ function provide(
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
config: Layer.Layer<Config.Service> = configLayer,
|
||||
plugins?: LayerNode.Replacement,
|
||||
replacements: LayerNode.Replacements = [],
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -137,6 +139,7 @@ function provide(
|
||||
Location.node.replace(locationLayer),
|
||||
plugins ?? PluginSupervisor.node.replace(Layer.empty),
|
||||
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
|
||||
...replacements,
|
||||
],
|
||||
)
|
||||
return Effect.provide(built)
|
||||
@@ -150,6 +153,7 @@ function withTmp<A, E, R>(
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
config?: Layer.Layer<Config.Service>
|
||||
plugins?: LayerNode.Replacement
|
||||
replacements?: LayerNode.Replacements
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -172,7 +176,16 @@ function withTmp<A, E, R>(
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap(({ tmp, vcs }) =>
|
||||
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
|
||||
f(tmp.path, vcs).pipe(
|
||||
provide(
|
||||
tmp.path,
|
||||
vcs,
|
||||
options?.watcher,
|
||||
options?.config ?? configLayer,
|
||||
options?.plugins,
|
||||
options?.replacements,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -293,6 +306,64 @@ describe("LocationWatcher subscriptions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.live("uses the policy changed while target discovery was suspended", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const discovering = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const subscribed = yield* Deferred.make<void>()
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
let released = 0
|
||||
yield* withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* Deferred.await(discovering)
|
||||
const update = yield* policy
|
||||
.transform((editor) => editor.add([".hg"]))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual([".hg"])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const registration = yield* Fiber.join(update)
|
||||
expect(subscriptions).toEqual([])
|
||||
|
||||
yield* registration.dispose
|
||||
yield* Deferred.await(subscribed)
|
||||
yield* policy.reload()
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
||||
expect(released).toBe(0)
|
||||
}),
|
||||
{
|
||||
vcs: "hg",
|
||||
replacements: [
|
||||
Plugin.node.replace(Layer.mock(Plugin.Service, { awaitActivation: Effect.void })),
|
||||
FSUtil.node.replace(
|
||||
Layer.succeed(FSUtil.Service, {
|
||||
...fs,
|
||||
realPath: (target) =>
|
||||
Deferred.succeed(discovering, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(fs.realPath(target)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
],
|
||||
watcher: Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => subscriptions.push(input)).pipe(
|
||||
Effect.andThen(Deferred.succeed(subscribed, undefined)),
|
||||
Effect.as(Stream.never.pipe(Stream.ensuring(Effect.sync(() => released++)))),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
expect(released).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not start before configured policy is ready", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
|
||||
@@ -83,6 +83,34 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists parents and siblings with paths relative to the current location", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
|
||||
expect(parent).toHaveLength(2)
|
||||
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: "." + path.sep, type: "directory" },
|
||||
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
|
||||
]),
|
||||
)
|
||||
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
|
||||
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
|
||||
])
|
||||
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
|
||||
expect(absolute).toEqual(sibling)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("canonicalizes local symlinked directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -105,10 +133,37 @@ describe("FileSystem", () => {
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows listing through an external symlink without allowing file reads", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
const outside = path.join(directory, "outside")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(outside))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
|
||||
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
|
||||
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
|
||||
])
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,6 +19,26 @@ const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus
|
||||
])
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("reads the current editor source by name", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const source = Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") })
|
||||
yield* references.transform((editor) => editor.add("docs", source))
|
||||
yield* references.transform((editor) => {
|
||||
expect(editor.get("docs")).toBe(editor.list()[0]?.[1])
|
||||
expect(editor.get("docs")).toEqual(source)
|
||||
expect(editor.get("missing")).toBeUndefined()
|
||||
const replacement = Reference.GitSource.make({ type: "git", repository: "owner/repo" })
|
||||
editor.add("docs", replacement)
|
||||
expect(editor.get("docs")).toBe(replacement)
|
||||
editor.remove("docs")
|
||||
expect(editor.get("docs")).toBeUndefined()
|
||||
})
|
||||
|
||||
expect(yield* references.list()).toEqual([])
|
||||
}).pipe(Effect.provide(referenceLayer)),
|
||||
)
|
||||
|
||||
it.effect("reads batched references before cache work and update events", () => {
|
||||
const operations: RepositoryCache.EnsureInput[] = []
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, setDefaultTimeout } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Clock, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { Clock, Duration, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
@@ -35,7 +35,7 @@ describe("RepositoryCache", () => {
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
|
||||
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
|
||||
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday })
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root)))
|
||||
|
||||
const results = yield* Effect.all(
|
||||
@@ -61,7 +61,30 @@ describe("RepositoryCache", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throttles failed refresh attempts without marking them successful", () =>
|
||||
it.live("honors legacy attempt records while allowing forced refreshes", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const kv = yield* KV.Service
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
|
||||
|
||||
// Persisted records from older versions include an unused success timestamp.
|
||||
yield* kv.set(`repository-cache:${initial.localPath}`, {
|
||||
attemptedAt: yield* Clock.currentTimeMillis,
|
||||
refreshedAt: 0,
|
||||
})
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
|
||||
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: true })).status).toBe("refreshed")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throttles failed refresh attempts until the next interval", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
@@ -69,31 +92,23 @@ describe("RepositoryCache", () => {
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
const key = `repository-cache:${initial.localPath}`
|
||||
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
|
||||
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
yield* kv.set(key, { attemptedAt: yesterday })
|
||||
yield* Effect.promise(() =>
|
||||
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
|
||||
)
|
||||
|
||||
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
|
||||
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
|
||||
const stored = yield* kv.get(key)
|
||||
const stamp = Schema.decodeUnknownSync(
|
||||
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
|
||||
)(stored)
|
||||
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
|
||||
expect(stamp.refreshedAt).toBe(yesterday)
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
|
||||
expect(yield* kv.get(key)).toEqual(stored)
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
|
||||
await commit(fixture.source, "two\n", "advance main")
|
||||
})
|
||||
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
|
||||
yield* kv.set(key, { attemptedAt: yesterday })
|
||||
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
|
||||
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
|
||||
expect(yield* kv.get(key)).not.toEqual(stored)
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
@@ -157,13 +172,13 @@ describe("RepositoryCache", () => {
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference })
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
yield* Effect.promise(async () => {
|
||||
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
|
||||
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
|
||||
})
|
||||
|
||||
const replaced = yield* cache.ensure({ reference: fixture.reference })
|
||||
const replaced = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
|
||||
|
||||
expect(replaced.status).toBe("cloned")
|
||||
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
|
||||
|
||||
@@ -20,6 +20,25 @@ const info = (id: string, description: string) =>
|
||||
})
|
||||
|
||||
describe("Skill", () => {
|
||||
it.effect("reads the current editor entry by ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.add(info("review", "Initial")))
|
||||
yield* skill.transform((editor) => {
|
||||
expect(editor.get("review")).toBe(editor.list()[0])
|
||||
expect(editor.get("missing")).toBeUndefined()
|
||||
editor.update("review", (value) => {
|
||||
value.description = "Updated"
|
||||
})
|
||||
expect(editor.get("review")?.description).toBe("Updated")
|
||||
editor.remove("review")
|
||||
expect(editor.get("review")).toBeUndefined()
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers values with last-write-wins precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3"
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
"puppeteer-core": "25.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
@@ -36,6 +38,8 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -46,6 +50,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import electron, { type BrowserWindow, type WebContents } from "electron"
|
||||
import type { Protocol } from "devtools-protocol"
|
||||
import { Schema } from "effect"
|
||||
import { createCdp, abortError, waitFor } from "./browser/cdp"
|
||||
import { createBrowserFiles } from "./browser/files"
|
||||
import { createDiagnostics } from "./browser/diagnostics"
|
||||
import { createProfiling } from "./browser/profiling"
|
||||
|
||||
type Element = { backendID: number; frameID: string; sessionID?: string }
|
||||
let nextRef = 0
|
||||
// Captures and downloads belong to the tab, not whichever document it now shows.
|
||||
const retainedOperations = new Set<Browser.Method>([
|
||||
"files.list",
|
||||
"files.get",
|
||||
"trace.stop",
|
||||
"trace.analyze",
|
||||
"cpu.stop",
|
||||
"cpu.analyze",
|
||||
"heap.summary",
|
||||
"heap.query",
|
||||
"heap.object",
|
||||
"heap.compare",
|
||||
])
|
||||
export type BrowserPage = ReturnType<typeof createBrowserPage>
|
||||
|
||||
export function createBrowserPage(
|
||||
win: BrowserWindow,
|
||||
options: {
|
||||
id: Browser.TabID
|
||||
partition: string
|
||||
publish: (error?: string) => void
|
||||
fail: () => void
|
||||
popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents
|
||||
initialize?: boolean
|
||||
popupOptions?: Electron.BrowserWindowConstructorOptions
|
||||
},
|
||||
) {
|
||||
const view = new electron.WebContentsView({
|
||||
...options.popupOptions,
|
||||
webPreferences: {
|
||||
partition: options.partition,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
devTools: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
const contents = view.webContents
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
|
||||
const step =
|
||||
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
|
||||
? 0.5
|
||||
: input.key === "-" || input.code === "NumpadSubtract"
|
||||
? -0.5
|
||||
: 0
|
||||
if (!step && input.key !== "0") return
|
||||
event.preventDefault()
|
||||
contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step)
|
||||
})
|
||||
const cdp = createCdp(contents)
|
||||
const files = createBrowserFiles()
|
||||
const diagnostics = createDiagnostics(cdp)
|
||||
const profiling = createProfiling(contents, cdp, files)
|
||||
const refs = new Map<string, Element>()
|
||||
const sessions = new Map<string, string>()
|
||||
const parents = new Map<string, string>()
|
||||
const contexts = new Map<string, { id: number; sessionID?: string }>()
|
||||
const dialogs = new Set<() => void>()
|
||||
let dialog: { type: string; message: string; defaultValue: string } | null = null
|
||||
let generation = 0
|
||||
let closed = false
|
||||
const state = (): Browser.Tab => ({
|
||||
id: options.id,
|
||||
url: contents.getURL().slice(0, 16_384),
|
||||
title: contents.getTitle().slice(0, 2_048),
|
||||
loading: contents.isLoading(),
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
generation,
|
||||
})
|
||||
const publish = () => {
|
||||
if (!closed) options.publish()
|
||||
}
|
||||
const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
if (!event.isMainFrame || event.isSameDocument) return
|
||||
generation++
|
||||
refs.clear()
|
||||
diagnostics.clear()
|
||||
publish()
|
||||
}
|
||||
contents.on("did-start-navigation", reset)
|
||||
contents.on("did-stop-loading", publish)
|
||||
contents.on("did-navigate-in-page", publish)
|
||||
contents.on("page-title-updated", publish)
|
||||
contents.on("render-process-gone", () => {
|
||||
if (!closed) options.fail()
|
||||
})
|
||||
contents.debugger.on("detach", () => {
|
||||
if (!closed) options.fail()
|
||||
})
|
||||
contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
|
||||
contents.session.setPermissionCheckHandler(() => false)
|
||||
contents.session.setDevicePermissionHandler(() => false)
|
||||
contents.session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
|
||||
contents.on("content-bounds-updated", (event) => event.preventDefault())
|
||||
const guard = (event: Electron.Event<{ url: string }>) => {
|
||||
if (event.url === "about:blank" || destinationOrigin(event.url)) return
|
||||
event.preventDefault()
|
||||
options.publish("ERR_BLOCKED_BY_CLIENT")
|
||||
}
|
||||
contents.on("will-frame-navigate", guard)
|
||||
contents.on("will-redirect", guard)
|
||||
contents.setWindowOpenHandler(({ url }) =>
|
||||
url === "about:blank" || destinationOrigin(url)
|
||||
? {
|
||||
action: "allow",
|
||||
outlivesOpener: true,
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
devTools: false,
|
||||
partition: options.partition,
|
||||
},
|
||||
},
|
||||
createWindow: (popupOptions) => options.popup(popupOptions),
|
||||
}
|
||||
: { action: "deny" },
|
||||
)
|
||||
const download = (_event: Electron.Event, item: Electron.DownloadItem, source: WebContents) => {
|
||||
if (source !== contents) return
|
||||
try {
|
||||
const file = files.add(item.getFilename(), item.getMimeType() || "application/octet-stream")
|
||||
item.setSavePath(file.path)
|
||||
item.on("updated", () => {
|
||||
file.bytes = item.getReceivedBytes()
|
||||
if (file.bytes > Browser.MAX_FILE_BYTES) item.cancel()
|
||||
})
|
||||
item.once("done", (_event, status) => {
|
||||
file.bytes = item.getReceivedBytes()
|
||||
file.state = status === "completed" ? "completed" : "failed"
|
||||
})
|
||||
} catch {
|
||||
item.cancel()
|
||||
options.publish("download_failed")
|
||||
}
|
||||
}
|
||||
contents.session.on("will-download", download)
|
||||
cdp.on("Runtime.executionContextCreated", ({ context }, sessionID) => {
|
||||
const aux = context.auxData as { frameId?: string; isDefault?: boolean } | undefined
|
||||
if (aux?.frameId && aux.isDefault) contexts.set(aux.frameId, { id: context.id, sessionID })
|
||||
})
|
||||
cdp.on("Runtime.executionContextDestroyed", ({ executionContextId }, sessionID) => {
|
||||
contexts.forEach((context, key) => {
|
||||
if (context.id === executionContextId && context.sessionID === sessionID) contexts.delete(key)
|
||||
})
|
||||
})
|
||||
cdp.on("Target.attachedToTarget", ({ sessionId, targetInfo }, parentSessionID) => {
|
||||
if (targetInfo.type !== "iframe") return
|
||||
const parentID = targetInfo.parentFrameId ?? Array.from(sessions).find(([, id]) => id === parentSessionID)?.[0]
|
||||
if (parentID) parents.set(targetInfo.targetId, parentID)
|
||||
sessions.set(targetInfo.targetId, sessionId)
|
||||
void Promise.all([
|
||||
diagnostics.enable(sessionId),
|
||||
cdp.send("Page.enable", {}, sessionId),
|
||||
cdp.send(
|
||||
"Target.setAutoAttach",
|
||||
{
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
filter: [{ type: "iframe", exclude: false }, { exclude: true }],
|
||||
},
|
||||
sessionId,
|
||||
),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
cdp.on("Target.detachedFromTarget", ({ sessionId }) => {
|
||||
sessions.forEach((id, frameID) => {
|
||||
if (id === sessionId) {
|
||||
sessions.delete(frameID)
|
||||
parents.delete(frameID)
|
||||
}
|
||||
})
|
||||
})
|
||||
cdp.on("Page.javascriptDialogOpening", (event) => {
|
||||
dialog = {
|
||||
type: event.type,
|
||||
message: event.message.slice(0, Browser.MAX_TEXT),
|
||||
defaultValue: event.defaultPrompt ?? "",
|
||||
}
|
||||
dialogs.forEach((reject) => reject())
|
||||
publish()
|
||||
})
|
||||
cdp.on("Page.javascriptDialogClosed", () => {
|
||||
dialog = null
|
||||
publish()
|
||||
})
|
||||
view.setBounds({ x: 0, y: 0, width: 1000, height: 700 })
|
||||
view.setVisible(false)
|
||||
view.setBorderRadius(10)
|
||||
win.contentView.addChildView(view)
|
||||
const ready = Promise.all([
|
||||
files.ready,
|
||||
...(options.initialize === false ? [] : [contents.loadURL("about:blank")]),
|
||||
diagnostics.enable(),
|
||||
cdp.send("Page.enable"),
|
||||
cdp.send("DOM.enable"),
|
||||
cdp.send("Target.setAutoAttach", {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
filter: [{ type: "iframe", exclude: false }, { exclude: true }],
|
||||
}),
|
||||
]).then(() => undefined)
|
||||
|
||||
return {
|
||||
view,
|
||||
contents,
|
||||
state,
|
||||
ready,
|
||||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
abortError(signal)
|
||||
if (closed)
|
||||
throw new Error(
|
||||
"Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID; do not reuse the closed tab's refs.",
|
||||
)
|
||||
if (
|
||||
command.generation !== undefined &&
|
||||
command.generation !== generation &&
|
||||
!retainedOperations.has(command.action.type)
|
||||
)
|
||||
throw new Error(
|
||||
"The document changed before this operation ran. Call browser.tabs.list({}) to check its current URL, then browser.snapshot({tabID}) for fresh refs. Reconsider the action before retrying on the new page.",
|
||||
)
|
||||
if (dialog && command.action.type !== "dialog")
|
||||
throw new Error(
|
||||
'A JavaScript dialog is open. Inspect it with browser.dialog({tabID,action:"get"}), then explicitly accept or dismiss it before continuing.',
|
||||
)
|
||||
const modal = Promise.withResolvers<never>()
|
||||
const cancelled = Promise.withResolvers<never>()
|
||||
const cancel = () =>
|
||||
cancelled.reject(
|
||||
new Error(
|
||||
"Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.",
|
||||
),
|
||||
)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
const reject = () =>
|
||||
modal.reject(
|
||||
new Error(
|
||||
'A JavaScript dialog opened while the action was running. Inspect it with browser.dialog({tabID,action:"get"}) and accept or dismiss it. Do not repeat the original action just to close the dialog.',
|
||||
),
|
||||
)
|
||||
if (command.action.type !== "dialog") dialogs.add(reject)
|
||||
try {
|
||||
return await Promise.race([execute(command.action, command.files, signal), modal.promise, cancelled.promise])
|
||||
} finally {
|
||||
signal.removeEventListener("abort", cancel)
|
||||
dialogs.delete(reject)
|
||||
}
|
||||
},
|
||||
async dispose() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
contents.session.off("will-download", download)
|
||||
await profiling.dispose()
|
||||
cdp.dispose()
|
||||
refs.clear()
|
||||
if (!win.isDestroyed()) win.contentView.removeChildView(view)
|
||||
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
|
||||
await files.dispose()
|
||||
},
|
||||
}
|
||||
|
||||
async function execute(
|
||||
action: Browser.Action,
|
||||
transfers: readonly Browser.File[],
|
||||
signal: AbortSignal,
|
||||
): Promise<Browser.Result> {
|
||||
const result = (value: unknown, attached: Browser.File[] = []): Browser.Result => {
|
||||
const json = Schema.decodeUnknownSync(Schema.Json)(value)
|
||||
if (JSON.stringify(json).length > 512_000)
|
||||
throw new Error(
|
||||
"Browser result exceeds 512000 JSON characters. Request fewer entries, reduce snapshot depth, or return only selected fields from the evaluation script. Repeating the same request will not reduce its output.",
|
||||
)
|
||||
return { value: json, files: attached }
|
||||
}
|
||||
switch (action.type) {
|
||||
case "navigate": {
|
||||
const url = normalizeURL(action.url)
|
||||
const cancel = () => contents.stop()
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
try {
|
||||
await contents.loadURL(url)
|
||||
} finally {
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
abortError(signal)
|
||||
return result(state())
|
||||
}
|
||||
case "back":
|
||||
case "forward":
|
||||
case "reload":
|
||||
case "stop": {
|
||||
if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
|
||||
if (action.type === "forward" && contents.navigationHistory.canGoForward())
|
||||
contents.navigationHistory.goForward()
|
||||
if (action.type === "reload") contents.reload()
|
||||
if (action.type === "stop") contents.stop()
|
||||
if (action.type !== "stop") await waitFor(() => !contents.isLoading(), signal, 30_000)
|
||||
return result(state())
|
||||
}
|
||||
case "frames":
|
||||
return result({ tab: state(), frames: await frames() })
|
||||
case "snapshot":
|
||||
case "find":
|
||||
return result({ tab: state(), ...(await snapshot(action)) })
|
||||
case "evaluate": {
|
||||
const context = action.frameID ? contexts.get(action.frameID) : undefined
|
||||
if (action.frameID && !context)
|
||||
throw new Error(
|
||||
"Frame context is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab, or omit frameID to target the main frame.",
|
||||
)
|
||||
const value = await cdp.send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: action.script,
|
||||
contextId: context?.id,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: true,
|
||||
},
|
||||
context?.sessionID,
|
||||
)
|
||||
if (value.exceptionDetails)
|
||||
throw new Error(
|
||||
`Page JavaScript threw an exception. Check the script and frameID; inspect the page before repeating code with side effects. Details: ${(value.exceptionDetails.exception?.description ?? value.exceptionDetails.text).slice(0, 800)}`,
|
||||
)
|
||||
abortError(signal)
|
||||
return result({ tab: state(), value: value.result.value ?? null })
|
||||
}
|
||||
case "click":
|
||||
await click(target(action.ref), action.button ?? "left", action.count ?? 1, action.modifiers)
|
||||
break
|
||||
case "hover":
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...(await point(target(action.ref))) })
|
||||
break
|
||||
case "drag": {
|
||||
const source = target(action.from)
|
||||
const destination = target(action.to)
|
||||
await point(destination)
|
||||
const from = await point(source)
|
||||
const box = await rect(destination)
|
||||
const to = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
const html5 = await call(source, "function() { return this.draggable; }")
|
||||
let data: Protocol.Input.DragData | undefined
|
||||
const off = cdp.on("Input.dragIntercepted", (event) => {
|
||||
data = event.data
|
||||
})
|
||||
await cdp.send("Input.setInterceptDrags", { enabled: true })
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...from })
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mousePressed",
|
||||
...from,
|
||||
button: "left",
|
||||
buttons: 1,
|
||||
clickCount: 1,
|
||||
})
|
||||
try {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
abortError(signal)
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: from.x + ((to.x - from.x) * i) / 10,
|
||||
y: from.y + ((to.y - from.y) * i) / 10,
|
||||
button: "left",
|
||||
buttons: 1,
|
||||
})
|
||||
}
|
||||
if (html5) await waitFor(() => data !== undefined, signal, 2_000)
|
||||
if (data) {
|
||||
for (const type of ["dragEnter", "dragOver", "drop"])
|
||||
await cdp.send("Input.dispatchDragEvent", { type, ...to, data })
|
||||
}
|
||||
} finally {
|
||||
off()
|
||||
await cdp.send("Input.setInterceptDrags", { enabled: false })
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...to, button: "left", clickCount: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
case "fill":
|
||||
await fill(target(action.ref), action.text)
|
||||
break
|
||||
case "fill_form":
|
||||
for (const field of action.fields) {
|
||||
abortError(signal)
|
||||
if (field.type === "text") await fill(target(field.ref), field.value)
|
||||
if (field.type === "select") await select(target(field.ref), field.values)
|
||||
if (field.type === "check") await check(target(field.ref), field.checked)
|
||||
}
|
||||
break
|
||||
case "select":
|
||||
await select(target(action.ref), action.values)
|
||||
break
|
||||
case "check":
|
||||
await check(target(action.ref), action.checked)
|
||||
break
|
||||
case "press":
|
||||
await key(action.key)
|
||||
break
|
||||
case "scroll": {
|
||||
const bounds = view.getBounds()
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseWheel",
|
||||
x: bounds.width / 2,
|
||||
y: bounds.height / 2,
|
||||
deltaX: action.deltaX ?? 0,
|
||||
deltaY: action.deltaY,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "wait": {
|
||||
if (action.condition !== "load" && !action.text)
|
||||
throw new Error(
|
||||
'browser.wait requires non-empty text for condition "text" or "textGone". Use condition "load" without text to wait for loading.',
|
||||
)
|
||||
await waitFor(
|
||||
async () => {
|
||||
if (action.condition === "load") return !contents.isLoading()
|
||||
const context = action.frameID ? contexts.get(action.frameID) : undefined
|
||||
if (action.frameID && !context)
|
||||
throw new Error(
|
||||
"Frame context is unavailable. Call browser.frames({tabID}) and use a frameID from this tab.",
|
||||
)
|
||||
const value = await cdp.send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: `document.body?.innerText.includes(${JSON.stringify(action.text)}) ?? false`,
|
||||
returnByValue: true,
|
||||
contextId: context?.id,
|
||||
},
|
||||
context?.sessionID,
|
||||
)
|
||||
return Boolean(value.result.value) === (action.condition === "text")
|
||||
},
|
||||
signal,
|
||||
action.timeoutMs,
|
||||
).catch((error) => {
|
||||
if (signal.aborted) throw error
|
||||
throw new Error(
|
||||
`browser.wait failed for condition ${JSON.stringify(action.condition)} (timeoutMs: ${action.timeoutMs ?? 10_000}). Inspect browser.snapshot({tabID}) and check text/frameID before retrying; timeoutMs can be increased up to 30000 for a genuinely slow page. Details: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
})
|
||||
break
|
||||
}
|
||||
case "screenshot": {
|
||||
if (action.ref && action.fullPage)
|
||||
throw new Error(
|
||||
"Choose either ref for an element screenshot or fullPage:true for the whole page. Remove the other argument before retrying.",
|
||||
)
|
||||
await waitFor(() => view.getVisible() && win.isVisible() && !win.isMinimized(), signal, 3_000).catch(
|
||||
(error) => {
|
||||
if (signal.aborted) throw error
|
||||
throw new Error(
|
||||
"Screenshot needs a visible tab. Call browser.tabs.focus and keep its desktop window visible.",
|
||||
)
|
||||
},
|
||||
)
|
||||
const element = action.ref ? await rect(target(action.ref), true) : undefined
|
||||
const metrics = await cdp.send("Page.getLayoutMetrics")
|
||||
const bounds = element
|
||||
? {
|
||||
...element,
|
||||
x: element.x + metrics.cssVisualViewport.pageX,
|
||||
y: element.y + metrics.cssVisualViewport.pageY,
|
||||
}
|
||||
: action.fullPage
|
||||
? metrics.cssContentSize
|
||||
: {
|
||||
x: metrics.cssVisualViewport.pageX,
|
||||
y: metrics.cssVisualViewport.pageY,
|
||||
width: metrics.cssVisualViewport.clientWidth,
|
||||
height: metrics.cssVisualViewport.clientHeight,
|
||||
}
|
||||
const pixelRatio = contents.getZoomFactor() * electron.screen.getDisplayMatching(win.getBounds()).scaleFactor
|
||||
const scale = Math.min(1, (action.maxWidth ?? 2000) / (bounds.width * pixelRatio))
|
||||
if (bounds.width <= 0 || bounds.height <= 0)
|
||||
throw new Error(
|
||||
"Element or page has no visible screenshot area. Take a fresh snapshot and choose a visible element, or omit ref to capture the viewport.",
|
||||
)
|
||||
if (bounds.width * bounds.height * (scale * pixelRatio) ** 2 > 16_000_000)
|
||||
throw new Error("Screenshot exceeds 16 megapixels; capture an element or use a smaller maxWidth.")
|
||||
const format = action.format ?? "png"
|
||||
const capture = await cdp.send("Page.captureScreenshot", {
|
||||
format,
|
||||
quality: format === "png" ? undefined : (action.quality ?? 80),
|
||||
captureBeyondViewport: true,
|
||||
clip: { ...bounds, scale },
|
||||
})
|
||||
const id = await files.save(`screenshot.${format}`, `image/${format}`, Buffer.from(capture.data, "base64"))
|
||||
return result({ tab: state() }, [await files.transfer(id)])
|
||||
}
|
||||
case "dialog": {
|
||||
if (action.action !== "get") {
|
||||
if (!dialog)
|
||||
throw new Error(
|
||||
'This tab has no JavaScript dialog to handle. browser.dialog({tabID,action:"get"}) returns null when none is open; continue without accepting or dismissing one.',
|
||||
)
|
||||
await cdp.send("Page.handleJavaScriptDialog", {
|
||||
accept: action.action === "accept",
|
||||
promptText: action.promptText,
|
||||
})
|
||||
dialog = null
|
||||
}
|
||||
return result({ tab: state(), dialog })
|
||||
}
|
||||
case "files.upload":
|
||||
case "files.drop": {
|
||||
if (!transfers.length)
|
||||
throw new Error(
|
||||
"Upload command has no file bytes. Supply server-local paths to browser.files.upload/drop; do not call the desktop RPC directly with desktop paths. If paths were supplied, report a client/server transfer mismatch.",
|
||||
)
|
||||
const local = await Promise.all(
|
||||
transfers.map(async (file) => files.get(await files.save(file.name, file.mime, file.data)).path),
|
||||
)
|
||||
const element = target(action.ref)
|
||||
if (action.type === "files.upload")
|
||||
await cdp.send("DOM.setFileInputFiles", { files: local, backendNodeId: element.backendID }, element.sessionID)
|
||||
if (action.type === "files.drop") {
|
||||
const position = await point(element)
|
||||
for (const type of ["dragEnter", "dragOver", "drop"])
|
||||
await cdp.send("Input.dispatchDragEvent", {
|
||||
type,
|
||||
...position,
|
||||
data: { items: [], files: local, dragOperationsMask: 1 },
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "files.list":
|
||||
return result({ tab: state(), files: files.list() })
|
||||
case "files.get":
|
||||
return result({ tab: state() }, [await files.transfer(action.fileID)])
|
||||
case "console":
|
||||
return result({ tab: state(), ...diagnostics.console(action) })
|
||||
case "network.list":
|
||||
return result({ tab: state(), ...diagnostics.list(action) })
|
||||
case "network.get":
|
||||
return result({ tab: state(), ...(await diagnostics.get(action)) })
|
||||
case "trace.start":
|
||||
await profiling.startTrace(action.durationMs)
|
||||
return result({ tab: state(), recording: true })
|
||||
case "trace.stop": {
|
||||
const value = await profiling.stopTrace()
|
||||
return result({ tab: state(), durationMs: value.durationMs, incomplete: value.incomplete }, [
|
||||
await files.transfer(value.id),
|
||||
])
|
||||
}
|
||||
case "cpu.start":
|
||||
await profiling.startCpu()
|
||||
return result({ tab: state(), recording: true })
|
||||
case "cpu.stop": {
|
||||
const value = await profiling.stopCpu()
|
||||
return result({ tab: state(), durationMs: value.durationMs }, [await files.transfer(value.id)])
|
||||
}
|
||||
case "heap.snapshot":
|
||||
return result({ tab: state() }, [await files.transfer(await profiling.heap())])
|
||||
case "trace.analyze":
|
||||
case "cpu.analyze":
|
||||
case "heap.summary":
|
||||
case "heap.query":
|
||||
case "heap.object":
|
||||
case "heap.compare":
|
||||
return result({ tab: state(), ...(await profiling.analyze(action)) })
|
||||
case "lighthouse": {
|
||||
const { audit } = await import("./browser/lighthouse")
|
||||
const report = await audit(contents, files, cdp)
|
||||
return result(
|
||||
{ tab: state(), scores: report.scores, failures: report.failures },
|
||||
await Promise.all(report.files.map((id) => files.transfer(id))),
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
"This operation was routed to a page instead of the tab manager. Report a desktop/plugin routing mismatch; changing tab IDs or repeating the operation will not fix it.",
|
||||
)
|
||||
}
|
||||
abortError(signal)
|
||||
return result(state())
|
||||
}
|
||||
|
||||
function target(ref: Browser.Ref): Element {
|
||||
const value = refs.get(ref.replace(/^@/, ""))
|
||||
if (!value)
|
||||
throw new Error(
|
||||
"Element ref is stale or belongs to another tab. Call browser.snapshot({tabID}) and use a ref from that tab's newest snapshot. Do not reuse refs after navigation or a newer snapshot.",
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
async function frames() {
|
||||
const root = await cdp.send("Page.getFrameTree")
|
||||
const result: { id: string; parentID?: string; url: string; name: string }[] = []
|
||||
const walk = (tree: Protocol.Page.FrameTree, parentID?: string) => {
|
||||
if (!result.some((frame) => frame.id === tree.frame.id))
|
||||
result.push({
|
||||
id: tree.frame.id,
|
||||
...(tree.frame.parentId || parentID ? { parentID: tree.frame.parentId ?? parentID } : {}),
|
||||
url: tree.frame.url,
|
||||
name: tree.frame.name ?? "",
|
||||
})
|
||||
tree.childFrames?.forEach((child) => walk(child, tree.frame.id))
|
||||
}
|
||||
walk(root.frameTree)
|
||||
const children = await Promise.all(
|
||||
Array.from(sessions, async ([id, sessionID]) => ({
|
||||
id,
|
||||
tree: await cdp.send("Page.getFrameTree", {}, sessionID).catch(() => undefined),
|
||||
})),
|
||||
)
|
||||
children.forEach(({ id, tree }) => {
|
||||
if (tree) walk(tree.frameTree, parents.get(id) ?? root.frameTree.frame.id)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function call(element: Element, functionDeclaration: string, args: unknown[] = []) {
|
||||
const object = await cdp.send("DOM.resolveNode", { backendNodeId: element.backendID }, element.sessionID)
|
||||
const objectId = object.object.objectId
|
||||
if (!objectId)
|
||||
throw new Error(
|
||||
"Element is no longer available. Call browser.snapshot({tabID}) and use a fresh ref; the page may have replaced the element.",
|
||||
)
|
||||
try {
|
||||
const result = await cdp.send(
|
||||
"Runtime.callFunctionOn",
|
||||
{
|
||||
objectId,
|
||||
functionDeclaration,
|
||||
arguments: args.map((value) => ({ value })),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture: true,
|
||||
},
|
||||
element.sessionID,
|
||||
)
|
||||
if (result.exceptionDetails)
|
||||
throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text)
|
||||
return result.result.value as unknown
|
||||
} finally {
|
||||
await cdp.send("Runtime.releaseObject", { objectId }, element.sessionID).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function rect(element: Element, scroll = false) {
|
||||
if (scroll) {
|
||||
await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendID }, element.sessionID)
|
||||
// Force a compositor update after scrolling. requestAnimationFrame can
|
||||
// stall in a hidden WebContentsView, even with background throttling off.
|
||||
await contents.capturePage(undefined, { stayHidden: false, stayAwake: true })
|
||||
}
|
||||
const shape = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
|
||||
const value = {
|
||||
...Schema.decodeUnknownSync(shape)(
|
||||
await call(
|
||||
element,
|
||||
"function() { const r = this.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height}; }",
|
||||
),
|
||||
),
|
||||
}
|
||||
const tree = await frames()
|
||||
let frame = tree.find((frame) => frame.id === element.frameID)
|
||||
while (frame?.parentID) {
|
||||
const owner = await cdp.send("DOM.getFrameOwner", { frameId: frame.id }, sessions.get(frame.parentID))
|
||||
const offset = Schema.decodeUnknownSync(shape)(
|
||||
await call(
|
||||
{ backendID: owner.backendNodeId, frameID: frame.parentID, sessionID: sessions.get(frame.parentID) },
|
||||
"function() { const r = this.getBoundingClientRect(); return {x:r.x+this.clientLeft,y:r.y+this.clientTop,width:r.width,height:r.height}; }",
|
||||
),
|
||||
)
|
||||
value.x += offset.x
|
||||
value.y += offset.y
|
||||
frame = tree.find((item) => item.id === frame?.parentID)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function point(element: Element) {
|
||||
const box = await rect(element, true)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
|
||||
async function click(element: Element, button = "left", count = 1, modifiers: readonly string[] = []) {
|
||||
const position = await point(element)
|
||||
const flags = modifiers.reduce((mask, key) => mask | ({ Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key] ?? 0), 0)
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...position, modifiers: flags })
|
||||
for (let clickCount = 1; clickCount <= count; clickCount++) {
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mousePressed",
|
||||
...position,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers: flags,
|
||||
})
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseReleased",
|
||||
...position,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers: flags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fill(element: Element, value: string) {
|
||||
const editable = await call(
|
||||
element,
|
||||
"function() { const input = this instanceof HTMLInputElement && !['file','checkbox','radio','button','submit','reset','image','hidden','range','color'].includes(this.type); return (input || this instanceof HTMLTextAreaElement || this.isContentEditable) && !this.disabled && !this.readOnly; }",
|
||||
)
|
||||
if (!editable)
|
||||
throw new Error(
|
||||
"Target is not an enabled editable text field. Take a fresh snapshot and choose a textbox; use browser.select for dropdowns, browser.check for checkboxes/radios, or browser.files.upload for file inputs.",
|
||||
)
|
||||
await cdp.send("DOM.focus", { backendNodeId: element.backendID }, element.sessionID)
|
||||
await key(process.platform === "darwin" ? "Meta+A" : "Control+A")
|
||||
await key("Backspace")
|
||||
await cdp.send("Input.insertText", { text: value })
|
||||
}
|
||||
|
||||
async function select(element: Element, values: readonly string[]) {
|
||||
await call(
|
||||
element,
|
||||
`function(values) { if (!(this instanceof HTMLSelectElement) || this.disabled) throw new Error('Target is not an enabled HTML select. Take a fresh snapshot and choose an enabled dropdown ref.'); if (!this.multiple && values.length !== 1) throw new Error('This dropdown accepts exactly one value; pass a one-item values array.'); for (const value of values) if (!Array.from(this.options).some(option => option.value === value && !option.disabled)) throw new Error('Option value was not found or is disabled. Inspect option values with browser.evaluate before retrying browser.select; values are not visible labels.'); for (const option of this.options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`,
|
||||
[values],
|
||||
)
|
||||
}
|
||||
|
||||
async function check(element: Element, checked: boolean) {
|
||||
const current = await call(
|
||||
element,
|
||||
"function(checked) { if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type) || this.disabled) throw new Error('Target is not an enabled checkbox or radio. Take a fresh snapshot and choose the correct ref.'); if (this.type === 'radio' && this.checked && !checked) throw new Error('A selected radio cannot be cleared by clicking it. Select a different radio in its group instead.'); return this.checked; }",
|
||||
[checked],
|
||||
)
|
||||
if (current !== checked) await click(element)
|
||||
if ((await call(element, "function() { return this.checked; }")) !== checked)
|
||||
throw new Error(
|
||||
"The page did not keep the requested checked state. Inspect the current snapshot and page validation before retrying; do not blindly toggle the control again.",
|
||||
)
|
||||
}
|
||||
|
||||
async function key(chord: string) {
|
||||
if (!chord)
|
||||
throw new Error(
|
||||
"A key is required. Use a named key such as Enter or ArrowDown, a single character, or a chord such as Control+A.",
|
||||
)
|
||||
const parts = (chord.endsWith("+") ? chord.slice(0, -1) : chord).split("+")
|
||||
const key = parts.pop() || "+"
|
||||
const modifiers = parts.reduce((mask, key) => {
|
||||
const bit = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }[key]
|
||||
if (!bit)
|
||||
throw new Error(
|
||||
`Unknown key modifier ${JSON.stringify(key)}. Supported modifiers are Alt, Control, Meta, and Shift; for example Control+A. Use Meta for macOS command shortcuts.`,
|
||||
)
|
||||
return mask | bit
|
||||
}, 0)
|
||||
const codes: Record<string, number> = {
|
||||
Enter: 13,
|
||||
Tab: 9,
|
||||
Escape: 27,
|
||||
Backspace: 8,
|
||||
Delete: 46,
|
||||
ArrowUp: 38,
|
||||
ArrowDown: 40,
|
||||
ArrowLeft: 37,
|
||||
ArrowRight: 39,
|
||||
PageUp: 33,
|
||||
PageDown: 34,
|
||||
Home: 36,
|
||||
End: 35,
|
||||
Space: 32,
|
||||
}
|
||||
const code =
|
||||
codes[key] ??
|
||||
(key.length === 1
|
||||
? key.toUpperCase().charCodeAt(0)
|
||||
: /^F([1-9]|1[0-2])$/.test(key)
|
||||
? 111 + Number(key.slice(1))
|
||||
: undefined)
|
||||
if (code === undefined)
|
||||
throw new Error(
|
||||
`Unknown key ${JSON.stringify(key)}. Use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, PageUp/Down, Home, End, Space, F1–F12, or one character. Use browser.fill for text.`,
|
||||
)
|
||||
const params = {
|
||||
key: key === "Space" ? " " : key,
|
||||
windowsVirtualKeyCode: code,
|
||||
modifiers,
|
||||
...(key.length === 1 && !(modifiers & 6) ? { text: key } : {}),
|
||||
}
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", ...params })
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...params })
|
||||
}
|
||||
|
||||
async function snapshot(action: Extract<Browser.Action, { type: "snapshot" | "find" }>) {
|
||||
const tree = await frames()
|
||||
const selected = action.type === "snapshot" && action.ref ? target(action.ref) : undefined
|
||||
const frameID = selected?.frameID ?? action.frameID ?? tree[0]?.id
|
||||
if (!frameID || !tree.some((frame) => frame.id === frameID))
|
||||
throw new Error(
|
||||
"Frame is unavailable. Call browser.frames({tabID}) and use a current frameID from this tab; omit frameID for the main frame.",
|
||||
)
|
||||
const sessionID = sessions.get(frameID)
|
||||
const depth = action.type === "snapshot" ? (action.depth ?? 8) : 8
|
||||
const ax = await cdp.send("Accessibility.getFullAXTree", { frameId: frameID, depth }, sessionID)
|
||||
const nodes = new Map(ax.nodes.map((node) => [node.nodeId, node]))
|
||||
const root = selected ? ax.nodes.find((node) => node.backendDOMNodeId === selected.backendID) : ax.nodes[0]
|
||||
if (!root)
|
||||
throw new Error(
|
||||
"Element is absent from this frame's accessibility snapshot. Retry browser.snapshot with the same tabID and no ref to refresh the frame, then choose a returned ref.",
|
||||
)
|
||||
refs.clear()
|
||||
const lines: string[] = []
|
||||
let truncated = false
|
||||
const walk = async (node: Protocol.Accessibility.AXNode, level: number): Promise<void> => {
|
||||
if (level > depth || lines.length >= 500) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
const role = String(node.role?.value ?? "node")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "")
|
||||
.slice(0, 40)
|
||||
const properties = new Map(node.properties?.map((property) => [property.name, property.value.value]) ?? [])
|
||||
if (!node.ignored) {
|
||||
const actionable =
|
||||
role !== "RootWebArea" &&
|
||||
(properties.get("focusable") || /^(button|link|textbox|combobox|checkbox|radio|option)$/.test(role))
|
||||
const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : ""
|
||||
const element = node.backendDOMNodeId ? { backendID: node.backendDOMNodeId, frameID, sessionID } : undefined
|
||||
if (ref && element) refs.set(ref, element)
|
||||
const flags = (["checked", "disabled", "expanded", "selected"] as const).flatMap((name) =>
|
||||
properties.has(name) ? [`${name}=${properties.get(name)}`] : [],
|
||||
)
|
||||
const box =
|
||||
action.type === "snapshot" && action.boxes && ref && element
|
||||
? await rect(element).catch(() => undefined)
|
||||
: undefined
|
||||
lines.push(
|
||||
`${" ".repeat(level)}${ref ? `@${ref} ` : ""}[${role}] ${JSON.stringify(
|
||||
String(node.name?.value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 300),
|
||||
)} ${flags.join(" ")}${box ? ` box=${JSON.stringify(box)}` : ""}`,
|
||||
)
|
||||
}
|
||||
if (["textbox", "searchbox"].includes(role) || properties.get("editable")) return
|
||||
for (const childID of node.childIds ?? []) {
|
||||
const child = nodes.get(childID)
|
||||
if (child) await walk(child, level + 1)
|
||||
}
|
||||
}
|
||||
await walk(root, 0)
|
||||
const content = (
|
||||
action.type === "find" ? lines.filter((line) => line.toLowerCase().includes(action.text.toLowerCase())) : lines
|
||||
).join("\n")
|
||||
return { content: content.slice(0, Browser.MAX_TEXT), truncated: truncated || content.length > Browser.MAX_TEXT }
|
||||
}
|
||||
}
|
||||
|
||||
export function destinationOrigin(input: string) {
|
||||
if (!URL.canParse(input)) return
|
||||
const url = new URL(input)
|
||||
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
|
||||
}
|
||||
|
||||
export function normalizeURL(input: string) {
|
||||
const value = input.trim() || "about:blank"
|
||||
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
|
||||
const url =
|
||||
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}`
|
||||
if (url !== "about:blank" && !destinationOrigin(url))
|
||||
throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported.")
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { destinationOrigin } from "./browser-chromium"
|
||||
|
||||
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
|
||||
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
|
||||
expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000")
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"https://user:pass@example.com",
|
||||
]) {
|
||||
expect(destinationOrigin(url)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user