Compare commits

...
Author SHA1 Message Date
LukeParkerDev e3da51c0a0 test(browser): reject denied sources over native RPC 2026-09-06 16:44:23 +10:00
LukeParkerDev 4952bf3bc7 fix(browser): authorize inspected targets before content access 2026-09-06 16:44:22 +10:00
LukeParkerDev 861094f8fa fix(browser): preserve permission feedback in tool errors 2026-09-06 16:44:21 +10:00
LukeParkerDev 5f443db61a test(plugin): bind the permission fixture instance 2026-09-06 16:44:21 +10:00
LukeParkerDev 1c915c0582 feat(browser): enforce URL permissions 2026-09-06 16:44:20 +10:00
LukeParkerDev 817ff19b82 test(plugin): use the current generation revision 2026-09-06 16:44:19 +10:00
LukeParkerDev ab00fd2d83 feat(plugin): expose permission assertions 2026-09-06 16:44:18 +10:00
LukeParkerDev 736568773d feat(desktop): route browser networking through plugin RPC 2026-09-06 16:44:13 +10:00
LukeParkerDev a9436b5727 fix(app): match browser masks to the panel backdrop 2026-09-06 16:44:12 +10:00
LukeParkerDev e77f4e0ecb fix(desktop): square the browser top corners 2026-09-06 16:44:11 +10:00
LukeParkerDev e5948583ed fix(app): make the experimental browser opt-in 2026-09-06 16:44:10 +10:00
LukeParkerDev 4e3ae93bf3 chore(desktop): format browser source guards 2026-09-06 16:44:09 +10:00
LukeParkerDev 05b2813713 fix(desktop): bind CPU exports to their capture sources 2026-09-06 16:44:08 +10:00
LukeParkerDev 1412f4d8f7 fix(desktop): inspect dialogs without querying blocked renderers 2026-09-06 16:44:07 +10:00
LukeParkerDev 5ba2a8becb fix(desktop): preserve browser target provenance and lifetime 2026-09-06 16:44:03 +10:00
LukeParkerDev b42f7291fe fix(desktop): clarify invalid browser input and capture failures 2026-09-06 16:44:00 +10:00
LukeParkerDev 13b00e9260 fix(desktop): give browser failures actionable recovery steps 2026-09-06 16:43:59 +10:00
LukeParkerDev a7332719b4 fix(desktop): require visible tabs for screenshots 2026-09-06 16:43:58 +10:00
LukeParkerDev b1448ea08c refactor(desktop): consume plugin-owned browser contracts 2026-09-06 16:43:57 +10:00
LukeParkerDev eb242163fc fix(desktop): preserve browser zoom and screenshot coordinates 2026-09-06 16:43:55 +10:00
LukeParkerDev a45a9c76f2 chore(app): format browser tab reconciliation 2026-09-06 16:43:54 +10:00
LukeParkerDev 6225a985b1 feat(desktop): add tabbed browser automation and diagnostics 2026-09-06 16:43:54 +10:00
LukeParkerDev 4287c530e1 fix(session-ui): capitalize browser tool label 2026-09-06 16:43:52 +10:00
LukeParkerDev a20e63dc8f fix(app): fit the browser pane into the review card 2026-09-06 16:43:50 +10:00
LukeParkerDev a79f814bac fix(desktop): bound browser registration and stop retrying unsupported servers 2026-09-06 16:43:49 +10:00
LukeParkerDev b676c4fe0e fix(app): keep the browser attachment and tab menu reliable 2026-09-06 16:43:48 +10:00
LukeParkerDev 5f6a75fa23 fix(app): hide the browser page under floating menus 2026-09-06 16:43:46 +10:00
LukeParkerDev 7bd2c34400 feat(app): open browser inside review tabs 2026-09-06 16:43:45 +10:00
LukeParkerDev e53c3aeaf5 test(app): include the browser settings default 2026-09-06 16:43:44 +10:00
LukeParkerDev 446f8db5ab refactor(desktop): use the shared browser contract 2026-09-06 16:43:43 +10:00
LukeParkerDev 717275d68b fix(desktop): publish acknowledged browser state 2026-09-06 16:43:41 +10:00
LukeParkerDev 8f6333ffed feat(desktop): connect browser pane through plugin RPC 2026-09-06 16:43:40 +10:00
LukeParkerDev 9b68605c0d feat(browser): relay network traffic through plugin RPC 2026-09-06 16:43:36 +10:00
LukeParkerDev 19fa09568b test(browser): remove SDK-host integration tests 2026-09-06 16:43:35 +10:00
LukeParkerDev 7e62b776c8 fix(browser): retain upload arguments during inspection 2026-09-06 16:43:34 +10:00
LukeParkerDev c7bd568edb fix(browser): expose target inspection and attachment replacement 2026-09-06 16:43:33 +10:00
LukeParkerDev 8ffaeec306 refactor(browser): separate connection and tool ownership 2026-09-06 16:43:33 +10:00
LukeParkerDev 26a0d25c53 fix(browser): return malformed capture errors as tool failures 2026-09-06 16:43:32 +10:00
LukeParkerDev 354f66bfb3 fix(browser): explain recovery from tool and transfer failures 2026-09-06 16:43:31 +10:00
LukeParkerDev f661df6263 docs(browser): specify screenshot visibility requirements 2026-09-06 16:43:30 +10:00
LukeParkerDev 5fa90405a8 refactor(browser): keep contracts inside the plugin package 2026-09-06 16:43:30 +10:00
LukeParkerDev a02210fcce docs(browser): clarify capture and remote deployment limits 2026-09-06 16:43:28 +10:00
LukeParkerDev 3fe159357b fix(browser): preserve transferred file names 2026-09-06 16:43:27 +10:00
LukeParkerDev 1b107c4f95 fix(browser): tighten wire contracts and file handling 2026-09-06 16:43:27 +10:00
LukeParkerDev 57c60ed282 feat(browser): define tab-targeted tools and RPC file transfers 2026-09-06 16:43:25 +10:00
LukeParkerDev 8876520b65 feat(browser): expose the browser through Code Mode 2026-09-06 16:43:24 +10:00
LukeParkerDev 982f6db5fb fix(sdk): resolve packaged worker paths on Windows 2026-09-06 16:43:23 +10:00
LukeParkerDev a483052733 fix(plugin-browser): keep release helpers out of the package 2026-09-06 16:43:22 +10:00
LukeParkerDev b2163e54e0 refactor(browser): extract the plugin into its own package 2026-09-06 16:43:21 +10:00
LukeParkerDev 67029d207b fix(browser): let a newer attachment replace a stale one 2026-09-06 16:43:19 +10:00
LukeParkerDev 510d3f4b67 refactor(browser): use canonical public schemas 2026-09-06 16:43:18 +10:00
LukeParkerDev df1bd25ebe refactor(browser): defer permission enforcement 2026-09-06 16:43:17 +10:00
LukeParkerDev d5c046d258 refactor(browser): colocate the public API plugin 2026-09-06 16:43:16 +10:00
LukeParkerDev 86836368bd feat(browser): add public RPC browser plugin 2026-09-06 16:43:14 +10:00
Aarav Sareen e64b2bc137 feat(app): add horizontal file sidebar scrolling + right click menu 2026-09-06 16:35:14 +10:00
opencode-agent[bot]andrekram1-node cf1923c238 fix(core): disable default GPT and Claude search filtering (#47586)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 01:22:48 -05:00
Luke Parker bfcb388dd7 feat(app): pulse the status dot while the event stream reconnects (#47574) 2026-09-06 15:13:38 +10:00
usrnk1 ec46439ef5 feat(desktop): simplify move to background action 2026-09-06 14:45:16 +10:00
Luke Parker 1be3b32a47 fix(client): detect stalled event streams and resync on foreground (#47571) 2026-09-06 04:43:50 +00:00
Aiden Cline 2823b886d7 feat(core): add independent GPT and Claude tool optimization (#47559) 2026-09-05 23:43:23 -05:00
Luke Parker 99651b2d50 fix(app): refresh queued inputs when the connection returns (#47573) 2026-09-06 04:42:27 +00:00
usrnk1 33ef66746b feat(desktop): add third-party web search consent 2026-09-06 14:38:55 +10:00
Luke Parker cf212a4235 fix(app): time out requests the server never answers (#47572) 2026-09-06 14:28:43 +10:00
David Hill 768e3d45a2 fix(ui): lighten dark contrast icon buttons 2026-09-06 14:01:47 +10:00
Luke Parker 0674ddf043 fix(client): coalesce catalog refetches from event bursts (#47561) 2026-09-06 03:48:49 +00:00
David Hill 41b66bc560 fix(app): scroll overflowing composer controls 2026-09-06 13:47:42 +10:00
opencode-agent[bot] 49a5baa059 chore: update nix node_modules hashes 2026-09-06 03:39:31 +00:00
Luke Parker 1cf576ab55 fix(desktop): keep server CORS headers so preflights cache (#47560) 2026-09-06 03:26:12 +00:00
David Hill f7d0f69e6c feat(app): add subtle new-session logo shimmer 2026-09-06 13:22:15 +10:00
Aiden Cline a66e6cd55e feat(core): discover Bedrock credentials in the provider plugin (#47548) 2026-09-05 22:20:48 -05:00
David Hill 93b3725b14 fix(app): move project icon setting to experimental 2026-09-06 13:07:37 +10:00
David Hill 371e344ba6 fix(app): widen new-session prompt beyond logo 2026-09-06 13:07:33 +10:00
David Hill 6559dcb01b fix(tui): list OpenCode Go before Zen 2026-09-06 13:07:29 +10:00
David Hill 35dbdfe287 fix(app): simplify vertical tabs footer 2026-09-06 13:07:24 +10:00
opencode-agent[bot] cd504dc66a chore: update nix node_modules hashes 2026-09-06 00:05:05 +00:00
Aiden Cline c4f2926ffb feat(ai): resolve Bedrock credentials through the AWS default chain (#47436) 2026-09-05 18:49:05 -05:00
opencode-agent[bot]andrekram1-node aa48433fa0 fix(core): send session ID with Copilot requests (#47213)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 18:42:46 -05:00
Kit Langton 8da7526616 fix(core): reload local plugin helpers without restarting
Reuse the shared dependency-aware plugin loader for Core hot reloads. Scope watcher setup callbacks and subscriptions, recover missing external helpers, and preserve explicit local entrypoint invalidation without reloading package dependencies. Add regression coverage and portable RPC fixtures.
2026-09-05 17:00:41 -04:00
opencode-agent[bot]andrekram1-node 7a4ad68af6 fix(mcp): retry initialization without codemode on HTTP 400 (#47507)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 15:49:25 +00:00
opencode-agent[bot]andnexxeln 2960c61f9c fix(tui): preserve parent scroll position across subagents (#47464)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 15:19:16 +05:30
opencode-agent[bot]andnexxeln 7ad705225e fix(tui): avoid disconnection error on terminal exit (#47272)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 14:35:40 +05:30
opencode-agent[bot]andHona 5045b3ce18 fix(ui): keep file accordion borders visible when scaled (#47461)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 08:47:54 +00:00
Aiden Cline 4306c07b34 feat(core): update GPT prompts and remove legacy Anthropic prompt (#47447) 2026-09-05 01:39:18 -05:00
opencode-agent[bot] 7de1e86b5d chore: update nix node_modules hashes 2026-09-05 06:38:20 +00:00
opencode-agent[bot]andrekram1-node a6fd1f8b22 chore: bump Bun to 1.4.2 (#47446)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 01:21:42 -05:00
Dax Raad 52685d4517 fix(tui): simplify home update notices 2026-09-05 02:17:09 -04:00
Luke Parker 30d1049942 fix(app): load worktree inventory on demand and cap concurrent server requests (#47441) 2026-09-05 05:25:43 +00:00
Stefan Avramandslickstef11 21adcb4969 fix(console): route enterprise forms to Chatwoot (#47437)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 00:05:40 -04:00
Kit Langton c72b535dee fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
2026-09-04 23:41:02 -04:00
opencode-agent[bot]andjlongster 8d1a9799f4 fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-04 22:46:28 -04:00
David Hill 86ba09c6e0 fix(app): increase vertical tabs minimum width (#47424) 2026-09-05 12:10:12 +10:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
228 changed files with 12819 additions and 2044 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.1
bun-version: 1.4.2
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.1
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+196 -17
View File
@@ -33,6 +33,7 @@
"name": "@opencode-ai/ai",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -62,6 +63,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:*",
@@ -338,7 +340,6 @@
"version": "1.18.4",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cohere": "3.0.27",
@@ -350,7 +351,6 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
@@ -358,6 +358,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 +417,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 +426,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 +438,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 +611,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": {
@@ -1067,7 +1088,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1189,7 +1210,7 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -1827,6 +1848,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=="],
@@ -2147,6 +2178,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"],
@@ -2211,6 +2244,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=="],
@@ -2221,6 +2256,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=="],
@@ -2493,6 +2530,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=="],
@@ -2549,6 +2588,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=="],
@@ -2747,10 +2788,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=="],
@@ -3317,6 +3366,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=="],
@@ -3487,7 +3538,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -3545,10 +3596,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=="],
@@ -3605,6 +3662,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=="],
@@ -3641,6 +3700,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=="],
@@ -3745,6 +3806,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=="],
@@ -3793,6 +3856,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=="],
@@ -3897,6 +3962,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=="],
@@ -3941,7 +4008,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=="],
@@ -4243,6 +4310,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=="],
@@ -4267,8 +4336,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=="],
@@ -4287,6 +4360,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=="],
@@ -4405,12 +4480,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=="],
@@ -4483,8 +4562,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=="],
@@ -4531,6 +4618,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=="],
@@ -4563,6 +4652,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=="],
@@ -4723,8 +4814,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=="],
@@ -5035,6 +5132,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=="],
@@ -5161,6 +5260,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=="],
@@ -5195,6 +5296,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=="],
@@ -5353,6 +5456,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=="],
@@ -5487,6 +5592,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=="],
@@ -5509,6 +5616,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=="],
@@ -5571,6 +5682,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=="],
@@ -5731,6 +5844,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=="],
@@ -5739,6 +5854,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=="],
@@ -5815,6 +5932,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=="],
@@ -5867,6 +5986,8 @@
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5875,8 +5996,6 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -5923,9 +6042,9 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6287,6 +6406,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=="],
@@ -6319,6 +6444,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=="],
@@ -6329,6 +6456,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=="],
@@ -6385,8 +6524,6 @@
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -6433,6 +6570,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 +6592,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 +6638,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 +6684,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 +6694,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 +6768,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 +6840,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=="],
@@ -7199,6 +7358,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=="],
@@ -7207,10 +7368,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=="],
@@ -7247,10 +7418,6 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -7379,6 +7546,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=="],
@@ -7415,6 +7584,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=="],
@@ -7437,6 +7608,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=="],
@@ -8181,6 +8358,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=="],
+2
View File
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
}
}
+3 -3
View File
@@ -5,9 +5,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.1",
"packageManager": "bun@1.4.2",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
@@ -159,7 +159,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:"
},
+1
View File
@@ -28,6 +28,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"@opencode-ai/schema": "workspace:*",
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth.js"
import { AIError, AuthenticationError } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
* which provider facades configure as route auth instead of SigV4.
*/
export interface Credentials {
readonly region: string
@@ -17,6 +16,44 @@ export interface Credentials {
readonly sessionToken?: string
}
/** Static credentials or an effect resolved before every request. */
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
export interface DefaultChainOptions {
readonly region: string
/** Shared config profile passed to the AWS default chain. */
readonly profile?: string
}
/**
* Resolve credentials through the AWS default provider chain: environment
* variables, shared config and SSO caches, web identity tokens, process
* credentials, and container or instance metadata. A fresh chain runs on every
* request so credentials rotated on disk without an expiration (for example
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
* the SDK's own memoization would otherwise pin them for the process lifetime.
*/
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
secretAccessKey: identity.secretAccessKey,
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
}
},
catch: (error) =>
new AIError({
reason: new AuthenticationError({
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
cause: error,
}),
}),
})
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -48,16 +85,17 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | undefined,
source: CredentialSource | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
if (!source) {
return yield* ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const credentials = Effect.isEffect(source) ? yield* source : source
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
@@ -74,4 +112,35 @@ export const sigV4 = (
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
export const auth = sigV4(undefined)
export const resolveRegion = (input: {
readonly region?: string
readonly credentials?: { readonly region: string }
}) =>
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
export interface ResolveAuthInput {
readonly apiKey?: string
readonly credentials?: Credentials
readonly profile?: string
}
export interface ResolveAuthOptions {
readonly service?: string
readonly name?: string
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
readonly mode?: "bearer" | "sigv4"
}
/**
* Bearer wins over SigV4 and explicit static credentials win over the default
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
* The region is applied to the SigV4 scope so it always matches the endpoint host.
*/
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
if (apiKey !== undefined) return Auth.bearer(apiKey)
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
return sigV4(defaultChain({ region, profile: input.profile }), options)
}
export * as BedrockAuth from "./bedrock-auth.js"
@@ -1,4 +1,3 @@
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: Credentials
/** Shared config profile for the default credential chain. */
readonly profile?: string
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
const region = BedrockAuth.resolveRegion(input)
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
mode: input.auth,
}),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
const {
apiKey: _,
auth: _auth,
baseURL: _baseURL,
credentials: _credentials,
profile: _profile,
region: _region,
...rest
} = input
return rest
}
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
const fromSettings = (settings: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
) => fromSettings(settings).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
) => fromSettings(settings).responses(modelID)
export const model = responsesModel
+20 -13
View File
@@ -1,17 +1,23 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly headers?: Record<string, string>
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
/** Shared config profile for the default credential chain. */
readonly profile?: string
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
})
}
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
region: settings.region,
}).model(modelID)
}
+30
View File
@@ -0,0 +1,30 @@
import { Effect } from "effect"
/**
* Run an effect with `process.env` overrides, restoring the previous values
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
* credential providers, where Effect `ConfigProvider` layers do not apply.
* `undefined` removes a variable for the duration of the effect.
*/
export const withProcessEnv =
(env: Record<string, string | undefined>) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
apply(env)
return previous
}),
() => effect,
(previous) => Effect.sync(() => apply(previous)),
)
const apply = (env: Record<string, string | undefined>) => {
for (const [name, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[name]
continue
}
process.env[name] = value
}
}
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
GenerationOptions,
type LanguageModel,
LLM,
LLMEvent,
LLMRequest,
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import {
eventSummary,
expectWeatherToolLoop,
@@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
)
// Clears every input the AWS default chain reads so tests do not pick up the
// developer's profiles, SSO cache, or instance metadata.
const noAmbientAWS = {
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_SESSION_TOKEN: undefined,
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_REGION: undefined,
AWS_DEFAULT_REGION: undefined,
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
AWS_EC2_METADATA_DISABLED: "true",
}
const captureHeaders = (target: LanguageModel) =>
Effect.gen(function* () {
const seen: Array<Headers> = []
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers)
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
headers: { "content-type": "application/vnd.amazon.eventstream" },
})
}),
),
),
)
return seen[0]!
})
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects requests with no auth path", () =>
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
profile: "opencode-test-missing-profile",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip,
)
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
}),
expect(error.reason._tag).toBe("Authentication")
expect(error.message).toContain("AWS default credential chain failed")
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
Effect.gen(function* () {
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
region: "eu-west-1",
credentials: {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
const headers = yield* captureHeaders(signed)
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
}),
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBeNull()
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(chained)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: "chain-session-token",
}),
),
)
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const first = yield* captureHeaders(chained)
const second = yield* captureHeaders(chained).pipe(
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
)
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
Effect.gen(function* () {
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(bearer)
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
Effect.gen(function* () {
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
auth: "sigv4",
baseURL: "https://bedrock-runtime.test",
})
for (const target of [configured, fromSettings]) {
const headers = yield* captureHeaders(target)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_REGION: "ap-southeast-2",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
Effect.gen(function* () {
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
const fromDefault = yield* Effect.sync(() =>
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
).pipe(withProcessEnv({ AWS_REGION: undefined }))
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
)
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
)
it.effect("signs with the Mantle service using default-chain credentials", () =>
Effect.gen(function* () {
const seen: Array<string | undefined> = []
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers.get("authorization") ?? undefined)
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
}).pipe(
withProcessEnv({
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: undefined,
}),
),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
+130 -7
View File
@@ -1,16 +1,139 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
for (const direction of ["ltr", "rtl"]) {
for (const alternate of ["none", "queue", "steer"]) {
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--toolbar-overflow", {
args: { alternate },
globals: { direction },
})
const controls = component.locator('[data-slot="composer-controls"]')
const actions = component.locator('[data-slot="composer-actions"]')
const submit = component.locator('[data-action="composer-submit"]')
const add = component.locator('[data-action="composer-attach"]')
const agent = controls.getByRole("button", { name: "Choose agent" })
const variant = controls.getByRole("button", { name: "Choose model variant" })
await expect(controls).toHaveCSS("overflow-x", "auto")
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
await expect(controls).toHaveCSS("direction", direction)
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeGreaterThan(0)
const fixed = await submit.boundingBox()
const fixedAdd = await add.boundingBox()
const viewport = await controls.boundingBox()
const action = await actions.boundingBox()
expect(fixed).not.toBeNull()
expect(viewport).not.toBeNull()
expect(action).not.toBeNull()
expect(fixedAdd).not.toBeNull()
if (!fixed || !viewport || !action || !fixedAdd) return
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
1,
)
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
direction === "ltr" ? action.x - 12 : viewport.x - 12,
1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
await controls.evaluate((element) => {
element.scrollLeft = 0
})
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
await expect(controls).toHaveCSS(
"mask-image",
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
)
const first = await agent.boundingBox()
if (!first) throw new Error("Missing agent control")
expect(
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
).toBeCloseTo(0, 0)
await controls.evaluate((element) => {
element.scrollLeft =
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
const scrolled = (await controls.boundingBox())!
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
await controls.hover()
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
expect(await submit.boundingBox()).toEqual(fixed)
expect(await add.boundingBox()).toEqual(fixedAdd)
// The fade disappears at the endpoint instead of reserving padding.
await variant.focus()
await controls.evaluate((element) => {
element.scrollLeft =
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
const last = await variant.boundingBox()
if (!last) throw new Error("Missing variant control")
expect(
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
).toBeLessThan(1)
expect(
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
await expect(add).toBeInViewport()
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
expect(await add.boundingBox()).toEqual(fixedAdd)
}
if (alternate !== "none") {
const width = (await controls.boundingBox())!.width
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
}
})
}
}
story("does not mask or pad controls when they fit", async ({ mount }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const controls = component.locator('[data-slot="composer-controls"]')
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
await expect(controls).toHaveCSS("mask-image", "none")
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
["light", "rgb(255, 255, 255)"],
["dark", "rgb(36, 36, 36)"],
] as const) {
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
})
}
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
@@ -0,0 +1,86 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
for (const direction of ["ltr", "rtl"]) {
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "no-preference" })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
const logo = component.locator('[data-component="new-session-wordmark"]')
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
const shimmer = logo.locator(".wordmark-shimmer")
const base = logo.locator("svg").first()
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
await expect(logo.locator("g[mask]")).toHaveCount(0)
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => {
animation.pause()
animation.currentTime = 0
})
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(reveal).toHaveCSS("animation-name", "none")
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
})
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(shimmer).toHaveCSS("opacity", "0")
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
const brightness = await page.evaluate(
async (screenshots) => {
const pixels = await Promise.all(
screenshots.map(async (screenshot) => {
const image = new Image()
image.src = `data:image/png;base64,${screenshot}`
await image.decode()
const canvas = new OffscreenCanvas(image.width, image.height)
const context = canvas.getContext("2d")
if (!context) throw new Error("Cannot read screenshot pixels")
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, image.width, image.height).data
}),
)
return {
min: pixels[0].reduce(
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
0,
),
max: pixels[0].reduce(
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
0,
),
}
},
[swept.toString("base64"), settled.toString("base64")],
)
expect(brightness.min).toBeGreaterThanOrEqual(0)
expect(brightness.max).toBeGreaterThanOrEqual(5)
})
}
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 360, height: 640 })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
const logo = component.locator('[data-component="new-session-wordmark"]')
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
})
}
@@ -0,0 +1,79 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: current.directory,
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
const longPath = `frontend/${longFilename}`
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
vcsDiff: [
{
file: longPath,
before: "",
after: "export const added = true\n",
additions: 1,
deletions: 0,
status: "added",
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
},
],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
@@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
type: "file" as const,
ignored: false,
},
{
name: longFilename,
path: `frontend\\${longFilename}`,
absolute: `${directory}/${longPath}`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
@@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
},
]
},
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
@@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
const longRow = panel.getByRole("button", { name: longFilename })
await expect(longRow).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
expect(
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
).toBe("clip")
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
await viewport.evaluate((element) => element.clientWidth),
)
await expect
.poll(() =>
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
const widths = rows.map((row) => row.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThanOrEqual(0.5)
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
await expect(status).toHaveText("A")
const statusBox = await status.boundingBox()
if (!statusBox) throw new Error("File status has no bounding box")
const viewportBox = await viewport.boundingBox()
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
await viewport.hover()
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
await expect(horizontalThumb).toHaveCSS("opacity", "1")
await page.mouse.wheel(1_000, 0)
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
await expect
.poll(() =>
status.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.right - element.getBoundingClientRect().right
}),
)
.toBeLessThanOrEqual(24)
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
const thumbBox = await horizontalThumb.boundingBox()
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill(longFilename)
const filteredRow = panel.getByRole("option", { name: longFilename })
await expect(filteredRow).toBeVisible()
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
await expect(filteredStatus).toHaveText("A")
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await viewport.evaluate((element) => {
element.setAttribute("dir", "rtl")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await expect
.poll(() =>
filteredStatus.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return element.getBoundingClientRect().left - viewport.left
}),
)
.toBeLessThanOrEqual(24)
const rtlThumbBox = await horizontalThumb.boundingBox()
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
await viewport.evaluate((element) => {
element.removeAttribute("dir")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await filter.fill("")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test.use({
deviceScaleFactor: 2,
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
launchOptions: { args: ["--force-device-scale-factor=2"] },
})
for (const width of [1400, 390]) {
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
"prt_border_patch",
"patch",
"completed",
{ patchText: "Update file" },
{
metadata: {
files: [
{
file,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
},
],
},
},
),
]),
],
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
const patch = page.locator('[data-component="apply-patch-tool"]')
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger).toHaveCSS("height", "32px")
for (const side of ["top", "right", "bottom", "left"]) {
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
}
const box = await trigger.boundingBox()
await trigger.hover()
expect(await trigger.boundingBox()).toEqual(box)
await page.mouse.move(0, 0)
expect(await trigger.boundingBox()).toEqual(box)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const content = patch.locator('[data-slot="accordion-content"]')
await expect(content).toBeVisible()
for (const side of ["left", "right", "bottom"]) {
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
}
await expect(content).toHaveCSS("border-top-width", "0px")
await trigger.press("Enter")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -396,7 +396,12 @@ async function mockServers(
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/location")
return json(route, {
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
@@ -225,19 +225,90 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await expect(sidebar).toHaveCSS("width", "140px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
for (const count of [0, 26]) {
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, directory, count }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: count }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_navigation_${index}`,
})),
),
)
},
{ server, directory: sessionA.directory, count },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
for (const width of [260, 180, 140]) {
if (width !== 260) {
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
const bounds = await sidebar.boundingBox()
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
await page.mouse.up()
}
await expect(sidebar).toHaveCSS("width", `${width}px`)
await testInfo.attach(`navigation-${count}-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
for (const name of ["Home", "New session"]) {
const button = sidebar.getByRole("button", { name, exact: true })
const label = button.getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await button.hover()
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await expect
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.getByRole("main").hover()
}
}
})
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -254,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const status = sidebar.getByRole("button", { name: "Status", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await expect(status).toHaveText("Status")
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
@@ -285,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -304,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(settings).toHaveCount(0)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -359,9 +431,9 @@ for (const profile of [
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect(hint).toBeVisible()
await expect
.poll(() =>
hint.evaluate((element) => {
@@ -373,7 +445,7 @@ for (const profile of [
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
@@ -381,10 +453,10 @@ for (const profile of [
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
})
}
@@ -560,7 +632,12 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectToken(
@@ -92,7 +92,7 @@ for (const theme of ["light", "dark"] as const) {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const url = page.url()
const refreshed = page.waitForResponse(
@@ -109,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
view.events.push({
id: "evt_workspace_accent_running",
@@ -127,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await expectBackground(stop, "icon-button-contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
})
test("new workspace send button stays neutral", async ({ page }) => {
const view = await openSession(page, root, [...inventory], true)
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
})
})
}
+1
View File
@@ -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:*",
+37 -5
View File
@@ -55,6 +55,8 @@ function ComposerStory(props: {
label?: string
inspectRequest?: boolean
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -66,11 +68,15 @@ function ComposerStory(props: {
activity: props.label ?? "Ready",
variant: STORY_MODEL.variant,
})
const modelOption = createMemo(() => ({
...selectedModel,
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
}))
const modelSelection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
current: () => modelOption(),
recent: () => [modelOption()],
list: () => [modelOption()],
cycle() {},
set() {},
visible: () => true,
@@ -126,7 +132,7 @@ function ComposerStory(props: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: "build" },
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
{ id: "review", label: "review" },
],
current: () => "build",
@@ -135,7 +141,7 @@ function ComposerStory(props: {
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: "balanced" },
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
@@ -144,6 +150,17 @@ function ComposerStory(props: {
submit: {
stopping: () => !!props.stopping,
working: () => !!props.working,
queue: props.alternate
? {
count: () => 0,
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
cancelEdit() {},
editFirst: () => false,
}
: undefined,
onSubmit: () => {
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
const request = props.inspectRequest
@@ -290,6 +307,21 @@ export const NarrowLayout = {
),
}
export const ToolbarOverflow = {
args: { alternate: "queue" },
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
render: (args: { alternate: "none" | "queue" | "steer" }) => (
<div class="w-[min(420px,calc(100vw-80px))]">
<ComposerStory
longLabels
working
prompt={text("Keep all controls reachable when the toolbar overflows")}
alternate={args.alternate === "none" ? undefined : args.alternate}
/>
</div>
),
}
export const DemoFirstClassSkillIDs = {
name: "Demo: First-class skill IDs",
render: () => (
@@ -2,6 +2,29 @@
content: "\200B";
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
}
[data-slot="composer-controls"][data-overflow-end="true"] {
--mask-end: 16px;
}
[data-slot="composer-controls"]:dir(rtl) {
--mask-direction: to left;
}
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
black var(--mask-start, 0px),
black calc(100% - var(--mask-end, 0px)),
transparent
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
+90 -60
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
setOverflow({
start: offset > 1,
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
observer.observe(controlsContent)
updateOverflow()
onCleanup(() => observer.disconnect())
})
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<div class="flex h-11 items-center px-2">
<div
class="flex min-w-0 flex-1 items-center gap-1"
class="flex shrink-0 items-center"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
<Show when={view.agent} keyed>
{(control) => (
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
</div>
<div
ref={controlsViewport}
data-slot="composer-controls"
data-overflow-start={overflow.start}
data-overflow-end={overflow.end}
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
onScroll={updateOverflow}
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
<Show when={view.agent} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
</Show>
</Show>
</div>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
<div data-slot="composer-actions" class="flex shrink-0 items-center">
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
</div>
</form>
</div>
@@ -808,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
tabIndex={props.mode === "normal" ? undefined : -1}
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
variant="contrast"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
class="size-7 rounded-md p-[6px] disabled:opacity-50"
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
+10
View File
@@ -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"
+8
View File
@@ -34,6 +34,14 @@ export function createHomeController() {
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const ctx = focusedServerCtx()
const id = selectedProject()?.id
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void ctx.sync.worktrees.load(root)
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project = homeProjectForSession(session, home.project.list())
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
test("labels a worktree session with its project before that project's inventory has loaded", () => {
const records = buildHomeSessionRecords({
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
projectDirectories: () => undefined,
projects: () => [{ ...opened, name: "Project A" }],
})
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
expect(records[0]?.projectName).toBe("Project A")
})
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
const records = buildHomeSessionRecords({
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
projectDirectories: () => undefined,
projects: () => [opened, nested],
})
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
+16 -7
View File
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const directory = pathKey(session.location.directory)
const project = input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
const project = homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
return { session, project, projectName: displayName(project) }
})
}
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
// the session's project ID still identifies its added project.
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
session: SessionInfo,
projects: readonly T[],
) {
const directory = pathKey(session.location.directory)
return (
projects.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projects.find((item) => item.id === session.projectID)
)
}
+2 -2
View File
@@ -1,2 +1,2 @@
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
+2 -2
View File
@@ -1,7 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
@@ -23,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -61,7 +61,7 @@ export function NewSessionView(props: {
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<Show when={props.project.empty()}>
+43
View File
@@ -0,0 +1,43 @@
[data-component="new-session-wordmark"] {
--wordmark-shimmer-opacity: 0.45;
.wordmark-shimmer {
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
color: white;
mix-blend-mode: screen;
opacity: 0;
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
animation: new-session-wordmark-shimmer 800ms linear both;
}
}
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
--wordmark-shimmer-opacity: 0.0672;
}
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
@keyframes new-session-wordmark-shimmer {
0% {
mask-position: 100% 0;
opacity: 0;
}
20%,
80% {
opacity: var(--wordmark-shimmer-opacity);
}
100% {
mask-position: 0% 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="new-session-wordmark"] {
.wordmark-shimmer {
animation: none;
}
}
}
@@ -0,0 +1,11 @@
import { NewSessionWordmark } from "./wordmark"
export default {
title: "App/New Session/Wordmark",
id: "app-new-session-wordmark",
component: NewSessionWordmark,
}
export const Reveal = {
render: () => <NewSessionWordmark />,
}
+17
View File
@@ -0,0 +1,17 @@
import { Wordmark } from "@opencode-ai/ui/wordmark"
import "./wordmark.css"
export function NewSessionWordmark() {
return (
<div
data-component="new-session-wordmark"
aria-hidden="true"
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
>
<div data-slot="wordmark-reveal" class="relative">
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
</div>
</div>
)
}
+25 -4
View File
@@ -666,9 +666,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",
@@ -676,7 +678,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.moveRunning": "Move to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -736,6 +738,16 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
@@ -810,6 +822,10 @@ 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.replaced": "Browser control moved to another desktop window.",
"session.browser.address.placeholder": "Enter a URL",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.channel.local": "Local",
@@ -1037,6 +1053,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",
@@ -1045,8 +1063,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.showProjectIcon.title": "Show project icon",
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
"settings.general.row.mobileDiffWrap.description":
@@ -1226,6 +1244,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,27 @@
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 }
background?: readonly [number, number, number, number]
radius?: 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 }
@@ -118,6 +119,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 &
+3 -1
View File
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
import { createRefCountMap } from "@/runtime/server/refcount"
import { createRequestQueue } from "@/runtime/server/request-queue"
import { ServerScope } from "@/runtime/server/scope"
import { useServer } from "./current"
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
const api = createApiForServer({ server: http, fetch: queue.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
@@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
@@ -20,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
@@ -47,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
// A refetch keeps the inventory a view already loaded for this project.
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
expect(store.project[0]?.worktrees).toEqual([
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
} finally {
queryClient.clear()
}
@@ -76,70 +88,39 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads each project's inventory through its own location using the real client", async () => {
const calls: string[] = []
test("loads project metadata without enumerating any project's worktrees", async () => {
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
calls.push(directory)
requests.push(url.pathname)
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
return Response.json([
{ directory },
{ directory: `${directory}/clone` },
{ directory: `${directory}/copy`, strategy: "git" },
...Array.from({ length: 300 }, (_, index) => ({
id: `historical-${index.toString().padStart(3, "0")}`,
canonical: `/history/${index}`,
time: { created: 1, updated: 1 },
sandboxes: [],
})),
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["/a", "/b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
return Response.json([{ directory: "/a/copy", strategy: "git" }])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
expect(requests).toEqual(["/api/project"])
expect(result).toHaveLength(302)
expect(result.slice(0, 2)).toMatchObject([
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
])
expect(result.some((project) => project.id === "test")).toBe(false)
})
})
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -64,42 +63,21 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ location: { directory: project.canonical } })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
projects.list().then((items) =>
items
.filter((project) => !!project?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
.sort((a, b) => cmp(a.id, b.id)),
),
),
})
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
serverAPI: {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
),
),
),
),
]
await runAll(slow)
}
@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue } from "./request-queue"
function setup(input?: { limit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
})
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
}
describe("createRequestQueue", () => {
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
expect(input.queue.queued()).toBe(1)
input.pending[0]!.resolve()
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
void input.queue.fetch("http://server/api/event")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
expect(input.queue.inflight()).toBe(1)
})
test("aborted requests leave the queue without being sent", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
void input.queue.fetch("http://server/api/first")
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
controller.abort()
await input.settle()
input.pending[0]!.resolve()
await expect(aborted).rejects.toBeInstanceOf(DOMException)
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
expect(input.queue.inflight()).toBe(0)
})
test("a request the server never answers times out and frees its slot", async () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
await input.settle()
expect(input.pending[0]!.signal.aborted).toBe(false)
controller.abort()
expect(input.pending[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toBeInstanceOf(DOMException)
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
await input.settle()
expect(input.queue.queued()).toBe(10)
// Drain two at a time before the stall threshold elapses.
for (let round = 0; round < 6; round++) {
input.pending.splice(0).forEach((item) => item.resolve())
await input.settle()
}
await Promise.all(responses)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([])
})
test("logs what is in flight and queued once per burst after requests stall", async () => {
const input = setup({ stallMs: 5 })
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/health").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([
{
message: "server thrashing detected",
data: {
limit: 2,
inflight: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
],
},
},
])
// Still stalled within the rate limit: no repeat.
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(1)
input.tick(10_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(2)
})
})
@@ -0,0 +1,100 @@
type Entry = { method: string; url: string; at: number }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
// a burst stalls probes and user actions inside the browser where nothing can observe it.
export const requestQueueLimit = 4
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
stallMs?: number
headersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
const inflight = new Set<Entry>()
const waiting: Array<{ entry: Entry; start: () => void }> = []
let warned = -Infinity
let watcher: ReturnType<typeof setTimeout> | undefined
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
// Debug exports include the console, so list what the server is busy with while requests wait.
const watch = () => {
watcher = undefined
const oldest = waiting[0]?.entry
if (!oldest) return
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
warned = now()
log("server thrashing detected", {
limit,
inflight: [...inflight].map(describe),
queued: waiting.map((item) => describe(item.entry)),
})
}
watcher = setTimeout(watch, stallMs)
}
const release = (entry: Entry) => {
inflight.delete(entry)
waiting.shift()?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (inflight.size < limit) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
// The event stream is long-lived; never count it against the request budget.
if (new URL(request.url).pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now() }
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
const controller = new AbortController()
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
release(entry)
})
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
)
return {
fetch,
inflight: () => inflight.size,
queued: () => waiting.length,
}
}
+27 -4
View File
@@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const worktrees = createWorktreeInventory({
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
),
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
@@ -197,17 +210,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
projects.map((project) =>
project.id === update.id
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
: project,
),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
if (root) void worktrees.refresh(root)
void bootstrap.refetch()
return
}
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
return
}
@@ -216,7 +239,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
})
onCleanup(unsub)
@@ -261,6 +283,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
worktrees,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+152
View File
@@ -0,0 +1,152 @@
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 eligible = createMemo(
() =>
!!platform.browserPane &&
settings.ready() &&
settings.general.experimentalBrowser() &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
!state.unsupported,
)
const available = createMemo(() => eligible() && session.isDesktop())
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 (!eligible() || !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)
if (event.error === "browser.pane.replaced") {
registration?.close()
registration = undefined
setState({ registration: undefined, browser: null, error: language.t("session.browser.replaced") })
return
}
// 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,
}
}
+180
View File
@@ -0,0 +1,180 @@
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
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
// 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)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next !== layout) {
layout = next
// Let the browser resolve the semantic backdrop color, including custom
// themes using color formats that Electron's color parser cannot read.
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
props.registration.setLayout({
tabID: tab.id,
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
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())
const appearance = new MutationObserver(() => schedule(300))
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => appearance.disconnect())
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
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>
)
}
@@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
+62 -23
View File
@@ -24,6 +24,11 @@ import {
type FileTreeV2Node,
} from "@/session/files/file-tree-v2-model"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
export type { Kind } from "@/session/files/file-tree"
@@ -99,7 +104,7 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
@@ -136,6 +141,9 @@ export default function FileTreeV2(props: {
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
@@ -217,6 +225,19 @@ export default function FileTreeV2(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
rows()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
}
>
<FileTreeNodeV2
@@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
</div>
)
}
export function syncFileTreeV2Width(element?: HTMLDivElement) {
if (!element) return
queueMicrotask(() => {
if (!element.isConnected) return
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
if (width <= element.clientWidth) return
element.style.width = `${width}px`
})
}
+56 -34
View File
@@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -49,6 +54,9 @@ export function SessionFileList(props: {
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
const normalized = createMemo(() => props.files.map(normalizePath))
@@ -89,6 +97,19 @@ export function SessionFileList(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
normalized()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -114,47 +135,48 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</button>
</OpenInAppContextMenuV2>
</div>
)}
</Show>
@@ -1,4 +1,4 @@
import { For, Show } from "solid-js"
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
const state = useOpenInApp({ path: props.directory })
return (
<Show when={props.directory() && state.canOpen()}>
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
state.openPath(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
describe("resolveOpenInAppPath", () => {
test("joins relative paths using the workspace separator", () => {
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
})
test("does not duplicate root separators", () => {
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
})
test("preserves backslashes in POSIX filenames", () => {
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
})
test("preserves absolute POSIX, Windows, and UNC paths", () => {
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
})
})
describe("openInAppParentPath", () => {
test("preserves POSIX and Windows roots", () => {
expect(openInAppParentPath("/file.ts")).toBe("/")
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
})
})
@@ -0,0 +1,19 @@
export function resolveOpenInAppPath(root: string, path: string) {
if (!path) return root
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
if (!root) return path
const separator = root.includes("\\") ? "\\" : "/"
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
}
export function openInAppParentPath(path: string) {
const value = path.replace(/[\\/]+$/, "")
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
if (index < 0) return path
if (index === 0) return value.slice(0, 1)
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
+30 -21
View File
@@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
export const OPEN_APPS = [
"vscode",
@@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
return fileManagerApp(os)
}
export function openAppsForOS(os: OpenAppOS) {
@@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
export function useOpenInApp(input: { directory: () => string }) {
export function useOpenInApp(input: { path: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
@@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) {
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
@@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) {
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
if (!target) return
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
const request =
app === "finder" && reveal && platform.revealPath
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
: open(target, openWith)
request
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
const copyPath = (target = input.path()) => {
if (!target) return
navigator.clipboard
.writeText(directory)
.writeText(target)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: directory,
description: target,
})
})
.catch((err: unknown) => showRequestError(language, err))
@@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) {
options,
menu,
setMenu,
openDir,
openPath,
selectApp,
copyPath,
}
}
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
const request = Promise.resolve(platform.checkAppExists?.(app))
.then(Boolean)
.catch(() => false)
appExistence.set(app, request)
return request
}
@@ -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("file.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,26 +412,73 @@ 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" />}
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" />}
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>
@@ -474,6 +530,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}
@@ -484,11 +564,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))}
+38
View File
@@ -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()
})
})
})
+14 -5
View File
@@ -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
})
+35 -5
View File
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
data.session.form.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
})
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory, workspace: session.location.workspaceID },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
websearch,
permissionRequest,
permissionResponding,
background: {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -23,6 +23,22 @@ const question = (id: string, sessionID: string) =>
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
describe("sessionTreeIDs", () => {
test("returns only the current session and its descendants", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
session({ id: "sibling", parentID: "root" }),
session({ id: "other" }),
]
expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
expect(sessionTreeIDs(sessions)).toEqual([])
})
})
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
@@ -81,7 +97,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionForm", () => {
describe("sessionFormRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -89,7 +105,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -102,15 +118,29 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips forms that are not questions", () => {
test("skips unsupported forms", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
})
test("finds web search consent in a nested child session", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
})
test("preserves request order across questions and web search", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
})
})
@@ -6,8 +6,16 @@ function sessionTreeRequest<T>(
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
if (!sessionID) return
const ids = sessionTreeIDs(session, sessionID)
if (!ids.length) return
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
}
export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
if (!sessionID) return []
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
@@ -27,11 +35,7 @@ function sessionTreeRequest<T>(
ids.push(child)
}
}
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
return ids
}
export function sessionPermissionRequest(
@@ -43,10 +47,15 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionForm(
export function sessionFormRequest(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string,
) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
return sessionTreeRequest(
session,
request,
sessionID,
(item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
)
}
@@ -0,0 +1,58 @@
[data-component="session-websearch-dock"] {
width: 100%;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 12px;
.websearch-body {
box-shadow: var(--v2-elevation-raised);
}
.websearch-setting {
padding: 16px;
}
.websearch-setting [data-component="settings-row"] {
column-gap: 24px;
padding-block: 0;
border: 0;
}
.websearch-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 24px 16px 12px;
border: 0;
}
.websearch-status {
display: flex;
align-items: center;
gap: 8px;
margin-inline-end: auto;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
.websearch-status:empty {
display: none;
}
.websearch-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
}
[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
width: max-content;
min-width: 0;
[data-component="menu-v2-item"] {
gap: 24px;
}
}
@@ -0,0 +1,86 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Select } from "@opencode-ai/ui/select"
import { useLanguage } from "@/runtime/i18n/language"
import { SettingsRow } from "@/settings/row"
import type { WebSearchRequestModel } from "./websearch"
import "./session-websearch-dock.css"
export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
const language = useLanguage()
const options = createMemo(() => [
...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
...props.model.options(),
])
const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
const busy = () => props.model.sending() || !props.model.connected()
const status = () => {
if (props.model.loading()) return language.t("common.loading")
if (props.model.failed()) return language.t("session.websearch.failed")
if (!props.model.options().length) return language.t("session.websearch.empty")
}
const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
const submit = (selection: string | false) => {
if (busy()) return
props.onSubmit()
void props.model.submit(selection)
}
return (
<section
data-component="session-websearch-dock"
aria-label={language.t("session.websearch.title")}
aria-busy={props.model.sending()}
>
<DockShell class="websearch-body">
<div class="websearch-setting">
<SettingsRow
title={language.t("session.websearch.title")}
description={language.t("session.websearch.description")}
>
<Select
aria-label={language.t("session.websearch.provider")}
options={options()}
current={current()}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && props.model.select(option.value)}
disabled={busy() || unavailable()}
placeholder={language.t("session.websearch.provider")}
contentClass="websearch-provider-menu"
/>
</SettingsRow>
</div>
</DockShell>
<DockTray attach="top" class="websearch-footer">
<div class="websearch-status" aria-live="polite">
<Show when={props.model.loadFailed()} fallback={status()}>
<span>{language.t("session.websearch.loadFailed")}</span>
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
{language.t("session.websearch.retry")}
</Button>
</Show>
</div>
<div class="websearch-actions">
<Show when={!props.model.specific()}>
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
{language.t("session.websearch.disable")}
</Button>
</Show>
<Button
variant="neutral"
size="small"
onClick={() => {
const selected = props.model.selected()
if (selected) submit(selected)
}}
disabled={busy() || unavailable() || !current()}
>
{language.t("session.websearch.enable")}
</Button>
</div>
</DockTray>
</section>
)
}
@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [
{
key: "provider",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
}
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
form: consent,
signal: abort.signal,
abort,
listeners,
replies,
events: {
listen(listener: (event: OpenCodeEvent) => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
cancel: (id: string) =>
emit({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
answer: (id: string, answer: FormAnswer) =>
emit({
id: "evt_reply",
created: 0,
type: "form.replied",
data: { id, sessionID: consent.sessionID, answer },
}),
}
}
describe("web search desktop consent", () => {
test.each([
["random", "allow"],
[false, "disable"],
] as const)("submits %s without a second form", async (selection, choice) => {
const input = fixture()
await replyWebSearch({ ...input, selection })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
expect(input.listeners.size).toBe(0)
})
test("subscribes before the first reply and answers the owning session's provider form", async () => {
const input = fixture()
await replyWebSearch({
...input,
selection: "parallel",
reply: async (answer) => {
input.replies.push(answer)
if (answer.answer.choice === "choose") input.create()
},
})
expect(input.replies).toEqual([
{ sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
])
expect(input.listeners.size).toBe(0)
})
test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
input.create({ ...provider, sessionID: "ses_other" })
input.create({ ...provider, metadata: { kind: "question" } })
input.create(consent)
input.create()
await pending
expect(input.replies).toHaveLength(2)
expect(input.replies[1]?.formID).toBe(provider.id)
})
test("leaves a changed provider list for explicit confirmation", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "removed" })
input.create()
await pending
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("supports direct entry into the provider form", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: "exa" })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
expect(input.listeners.size).toBe(0)
})
test("does not misrepresent cancelling the provider form as disabling search", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: false })
expect(input.replies).toEqual([])
})
test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
if (action === "cancel") input.cancel(consent.id)
if (action === "other-client") input.answer(consent.id, { choice: "disable" })
if (action === "abort") input.abort.abort()
await pending
input.create()
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("cleans up after a failed consent submission", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async () => {
throw new Error("offline")
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
test("propagates provider submission failures for retry", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async (answer) => {
if (answer.formID === provider.id) throw new Error("offline")
input.create()
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
})
@@ -0,0 +1,154 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
export function webSearchProviderField(form: FormInfo) {
return form.fields.find(
(field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
)
}
export function createWebSearchRequest(input: {
owner: () => string | undefined
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
selected: "random",
sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
error: false,
})
const [providers, resource] = createResource(input.request, async (form) => {
const field = webSearchProviderField(form)
if (field) return field.options ?? []
return input.providers(form.sessionID)
})
const request = createMemo(() => store.sending?.form ?? input.request())
const specific = createMemo(() => {
const form = request()
return !!form && !!webSearchProviderField(form)
})
const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
const selected = createMemo(() => {
if (!specific()) return store.selected
return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
})
createEffect(
on([input.owner, input.connected], () => {
store.sending?.abort.abort()
setStore({ sending: undefined, selected: "random", error: false })
}),
)
createEffect(
on(
() => input.request()?.id,
() => {
const form = input.request()
if (!form || store.sending || webSearchProviderField(form)) return
setStore({ selected: "random", error: false })
},
),
)
onCleanup(() => store.sending?.abort.abort())
const submit = async (selection: string | false) => {
const form = input.request()
if (!form || store.sending || !input.connected()) return
if (selection === false && webSearchProviderField(form)) return
const sending = { form, abort: new AbortController() }
setStore({ sending, error: false })
await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
.catch(() => {
if (!sending.abort.signal.aborted) setStore("error", true)
})
.finally(() => {
if (store.sending?.abort === sending.abort) setStore("sending", undefined)
})
}
return {
request,
options,
selected,
specific,
loading: () => providers.loading,
loadFailed: () => !!providers.error,
failed: () => store.error,
sending: () => !!store.sending,
connected: input.connected,
select: (value: string) => setStore({ selected: value, error: false }),
retry: () => void resource.refetch(),
submit,
}
}
export type WebSearchRequestModel = ReturnType<typeof createWebSearchRequest>
export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
if (webSearchProviderField(input.form)) {
if (input.selection === false) return
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { provider: input.selection },
})
}
if (input.selection === false || input.selection === "random") {
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { choice: input.selection === false ? "disable" : "allow" },
})
}
const next = Promise.withResolvers<FormInfo | undefined>()
const stop = input.events.listen((event) => {
if (event.type === "form.created") {
const form = event.data.form
if (
form.sessionID !== input.form.sessionID ||
form.id === input.form.id ||
form.metadata?.kind !== "websearch.provider" ||
!webSearchProviderField(form)
)
return
next.resolve(form)
}
if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
next.resolve(undefined)
})
const cancel = () => next.resolve(undefined)
input.signal.addEventListener("abort", cancel, { once: true })
return Promise.all([
input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
next.promise,
])
.then(([, form]) => {
if (!form || input.signal.aborted) return
const field = webSearchProviderField(form)
if (!field?.options?.some((option) => option.value === input.selection)) return
return input.reply({
sessionID: form.sessionID,
formID: form.id,
answer: { provider: input.selection },
})
})
.finally(() => {
stop()
input.signal.removeEventListener("abort", cancel)
})
}
+7 -1
View File
@@ -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>
)
+3 -1
View File
@@ -31,6 +31,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -45,6 +46,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
@@ -368,7 +370,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>
@@ -2,30 +2,54 @@ import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
function store() {
const syncs = { session: 0, message: 0, pending: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
pending: {
sync: () => {
syncs.pending++
return Promise.resolve()
},
},
}
return { syncs, sessions }
}
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const input = store()
const session = createSessionResolution(
() => undefined,
() => sessions,
() => input.sessions,
)
expect(session()).toBeUndefined()
expect(syncs).toEqual({ session: 0, message: 0 })
expect(input.syncs).toEqual({ session: 0, message: 0, pending: 0 })
dispose()
})
})
test("starts the transcript and queued input reads with metadata", () => {
createRoot((dispose) => {
const input = store()
createSessionResolution(
() => "ses_open",
() => input.sessions,
{ children: true },
)
expect(input.syncs).toEqual({ session: 1, message: 1, pending: 1 })
dispose()
})
})
@@ -7,6 +7,9 @@ type SessionStore<T> = {
message: {
sync: (id: string) => Promise<unknown>
}
pending: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -52,8 +55,11 @@ export function createSessionResolution<T>(
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
// The timeline owns message errors; metadata resolution stays independent. Queued inputs
// ride along so a reconnect refreshes them with the transcript instead of leaving the
// pre-disconnect queue on screen.
void store.message.sync(id).catch(() => undefined)
void store.pending.sync(id).catch(() => undefined)
if (cached() && !options?.children && !options?.connected) {
setStatus({ id, store, state: "settled" })
return
@@ -138,6 +138,26 @@ export const QuestionRequest = {
),
}
export const WebSearchRequest = {
render: () => (
<SessionPreview
title="Search for current documentation"
description={description}
document={questionPendingDocument}
request={{
type: "websearch",
value: {
id: "frm_websearch_preview",
sessionID: "ses_websearch_preview",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
},
}}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
+25 -1
View File
@@ -23,6 +23,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -82,7 +83,7 @@ export type SessionPreviewProps = {
description: string
document: SessionDocument
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
@@ -173,10 +174,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
activity: string
reviewOpened: boolean
request: SessionPreviewProps["request"]
searchProvider: string
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
request: props.request,
searchProvider: "random",
})
const prompt = createPromptController({
initial: props.draft ?? "",
@@ -189,6 +192,27 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
websearch: {
request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
options: () => [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
{ value: "tavily", label: "Tavily" },
],
selected: () => state.searchProvider,
specific: () => false,
loading: () => false,
loadFailed: () => false,
failed: () => false,
sending: () => false,
connected: () => true,
select: (value) => setState("searchProvider", value),
retry() {},
submit: async (value) => {
setState("request", undefined)
setState("activity", `Web search selection (local only): ${value}`)
},
} satisfies WebSearchRequestModel,
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
@@ -1,7 +1,8 @@
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
@@ -12,6 +13,7 @@ const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
@@ -29,6 +31,22 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<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={settings.general.setExperimentalBrowser}
hideLabel
>
{language.t("settings.general.row.browserPane.title")}
</Switch>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
@@ -61,6 +79,22 @@ export const SettingsExperimental: Component = () => {
</Switch>
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
description={language.t("settings.general.row.showProjectIcon.description")}
>
<div data-action="settings-show-project-icon">
<Switch
checked={settings.general.showProjectIcon()}
onChange={settings.general.setShowProjectIcon}
hideLabel
>
{language.t("settings.general.row.showProjectIcon.title")}
</Switch>
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</div>
@@ -348,20 +348,6 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
description={language.t("settings.general.row.showProjectIcon.description")}
>
<div data-action="settings-show-project-icon">
<Switch
checked={settings.general.showProjectIcon()}
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
/>
</div>
</SettingsRow>
</Show>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
+7
View File
@@ -67,6 +67,7 @@ describe("settings schema", () => {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: false,
},
appearance: {
fontSize: 14,
@@ -130,6 +131,12 @@ describe("settings schema", () => {
expect(decode(encode(settings))).toEqual(settings)
})
test("browser attachment is opt-in and preserves an explicit choice", () => {
expect(decode({}).general.experimentalBrowser).toBe(false)
expect(decode({ general: { experimentalBrowser: true } }).general.experimentalBrowser).toBe(true)
expect(decode({ general: { experimentalBrowser: false } }).general.experimentalBrowser).toBe(false)
})
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
"defaults malformed sections without losing other sections: %j",
(invalid) => {
+9
View File
@@ -95,6 +95,7 @@ const generalSchema = Persistence.struct({
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
experimentalBrowser: Schema.Boolean,
})
const appearanceSchema = Persistence.struct({
@@ -251,6 +252,7 @@ export const defaultSettings: Settings = {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: false,
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
@@ -358,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,
@@ -1,6 +1,6 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
+1 -1
View File
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
min={140}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
@@ -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
@@ -21,6 +21,19 @@ describe("serverStatusDotClass", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
"bg-icon-critical-base",
)
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
@@ -20,8 +20,12 @@ export function serverStatusDotClass(input: {
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
if (input.connecting) return "bg-border-weak-base animate-pulse"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
@@ -20,6 +20,7 @@ export function StatusPopover() {
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
@@ -41,8 +42,10 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -61,6 +64,8 @@ type StatusPopoverState = {
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
@@ -93,21 +98,37 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButton}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
triggerAs={props.state.sidebar ? "button" : IconButton}
triggerProps={
props.state.sidebar
? {
type: "button",
class:
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
"data-state": props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
: {
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
}
trigger={
<div class="relative size-4">
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
<>
<div class="relative size-4 shrink-0">
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
data-slot="status-indicator"
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
<Show when={props.state.sidebar}>
<span class="min-w-0 truncate">{props.state.label}</span>
</Show>
</>
}
{...popoverProps}
>
@@ -38,9 +38,15 @@ export function createTitlebarRightSlot(): TitlebarRightSlot {
}
}
export function TitlebarRightMount() {
export function TitlebarRightMount(props: { vertical?: boolean }) {
const slot = useTitlebarRightSlot()
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
return (
<div
ref={slot.setMount}
id="opencode-titlebar-right"
class={props.vertical ? "flex w-full shrink-0 flex-col" : "flex shrink-0 items-center justify-end gap-0"}
/>
)
}
export function TitlebarRight(props: ParentProps) {
+6 -18
View File
@@ -356,10 +356,10 @@ export function Titlebar(props: {
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
<Icon name="grid-plus" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("home.title")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
@@ -654,10 +654,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
<Icon name="edit" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
@@ -680,20 +680,8 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<button
type="button"
data-action="vertical-tabs-settings"
data-state={layout.route().type === "settings" ? "pressed" : undefined}
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
onClick={openSettings}
aria-label={language.t("sidebar.settings")}
aria-pressed={layout.route().type === "settings"}
>
<Icon name="settings-gear" />
{language.t("sidebar.settings")}
</button>
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
<TitlebarRightMount />
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
<TitlebarRightMount vertical />
</div>
</Portal>
)}
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
import { ServerScope } from "@/runtime/server/scope"
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
const client = new QueryClient()
const calls: string[] = []
const updates: Array<[string, WorktreeDirectory[]]> = []
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: (input) => {
const directory = input!.location!.directory!
calls.push(directory)
return list(directory)
},
}),
updated: (directory, items) => updates.push([directory, items]),
})
return { client, calls, updates, inventory }
}
describe("createWorktreeInventory", () => {
test("loads once per project, shares in-flight work, and publishes the result", async () => {
const gate = Promise.withResolvers<void>()
const setupResult = setup(async (directory) => {
await gate.promise
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
})
const first = setupResult.inventory.load("/repo")
const second = setupResult.inventory.load("/repo/")
expect(setupResult.calls).toEqual(["/repo"])
gate.resolve()
expect(await first).toHaveLength(2)
expect(await second).toHaveLength(2)
await setupResult.inventory.load("/repo")
expect(setupResult.calls).toEqual(["/repo"])
expect(setupResult.updates).toEqual([
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
])
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
setupResult.client.clear()
})
test("refreshes only inventories a view already loaded", async () => {
const setupResult = setup(async (directory) => [{ directory }])
await setupResult.inventory.refresh("/never-opened")
expect(setupResult.calls).toEqual([])
await setupResult.inventory.load("/opened")
await setupResult.inventory.refresh("/opened")
expect(setupResult.calls).toEqual(["/opened", "/opened"])
setupResult.client.clear()
})
test("a failed load is not cached and never rejects the caller", async () => {
let fail = true
const setupResult = setup(async (directory) => {
if (fail) throw new Error("Location unavailable")
return [{ directory }]
})
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
fail = false
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
expect(setupResult.calls).toEqual(["/repo", "/repo"])
setupResult.client.clear()
})
test("keys are partitioned by server and normalized by path", () => {
const remote = "https://remote.example" as typeof ServerScope.local
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
)
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
})
})
describe("withWorktreeInventory", () => {
const metadata = {
id: "project",
canonical: "/repo",
name: "Before",
time: { created: 1, updated: 1 },
sandboxes: [],
}
test("derives the workspace list from the inventory, excluding the project root", () => {
const worktrees = [
{ directory: "/repo/" },
{ directory: "/repo/feature", strategy: "git" },
{ directory: "/elsewhere" },
]
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
worktree: "/repo",
sandboxes: ["/repo/feature", "/elsewhere"],
worktrees,
})
})
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
const project = normalizeProjectInfo(metadata)
expect(withWorktreeInventory(project, undefined)).toBe(project)
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerScope } from "@/runtime/server/scope"
import type { Project } from "@/runtime/server/types"
import { pathKey } from "./path-key"
import { sameDirectory } from "./paths"
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
return [scope, "worktree", pathKey(directory)] as const
}
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
if (!worktrees) return project
return {
...project,
worktrees: [...worktrees],
sandboxes: worktrees
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.worktree, directory)),
}
}
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
// projects the user is looking at are loaded. Historical projects stay metadata-only.
export function createWorktreeInventory(input: {
scope: ServerScope
queryClient: QueryClient
api: () => Pick<ServerApi["worktree"], "list">
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
}) {
const options = (directory: string) => ({
queryKey: worktreeInventoryKey(input.scope, directory),
queryFn: () =>
input
.api()
.list({ location: { directory } })
.then((items) => {
input.updated(directory, items)
return items
}),
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
staleTime: Infinity,
gcTime: Infinity,
retry: false,
})
return {
cached: (directory: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
// Only inventories some view already demanded are refreshed.
refresh: (directory: string) => {
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
},
}
}
+10 -1
View File
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
import { retry } from "@opencode-ai/util/retry"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
@@ -15,6 +15,7 @@ const context = createSimpleContext({
name: "Location",
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const ref = createMemo(
() => ({
@@ -40,6 +41,14 @@ const context = createSimpleContext({
retryIf: () => !stale,
}).catch(() => undefined)
})
createEffect(() => {
const id = current()?.project.id
if (!id || serverSDK.connection.status() !== "connected") return
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
// Key it by the metadata root so the result merges into the same global project record.
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void server.ctx.sync.worktrees.load(root)
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
@@ -17,9 +17,11 @@ function createFixture(initial: Record<string, Session> = {}) {
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
const pending = { syncs: [] as string[] }
return {
resolves,
messages,
pending,
sessions: {
get: (id: string) => cache()[id],
sync: (id: string) => {
@@ -34,6 +36,12 @@ function createFixture(initial: Record<string, Session> = {}) {
return messages.promise
},
},
pending: {
sync: (id: string) => {
pending.syncs.push(id)
return Promise.resolve()
},
},
},
settle(id: string, directory = `/dir/${id}`) {
setCache({ ...cache(), [id]: { id, directory } })
@@ -86,6 +94,7 @@ test("refreshes the current session on reconnect while keeping cached content vi
expect(fixture.resolves).toEqual(["ses_a", "ses_a"])
expect(current()).toEqual(sessionOf("ses_a"))
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"])
expect(fixture.pending.syncs).toEqual(["ses_a", "ses_a"])
fixture.settle("ses_a", "/worktrees/moved")
await flush()
expect(current()?.directory).toBe("/worktrees/moved")
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createWebSearchRequest } from "@/session/requests/websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const options = [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
]
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [{ key: "provider", type: "string", options, required: true, custom: false }],
}
const cleanups: VoidFunction[] = []
afterEach(() => cleanups.splice(0).forEach((dispose) => dispose()))
function ready(condition: () => boolean) {
return new Promise<void>((resolve) => {
createRoot((dispose) => {
cleanups.push(dispose)
createEffect(() => {
if (!condition()) return
dispose()
resolve()
})
})
})
}
function fixture(form: FormCreated["data"]["form"] | null = consent) {
return createRoot((dispose) => {
cleanups.push(dispose)
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const loads: string[] = []
const [state, setState] = createStore({
request: form ?? undefined,
owner: "ses_root",
connected: true,
loadFails: false,
replyFails: false,
})
const model = createWebSearchRequest({
owner: () => state.owner,
connected: () => state.connected,
request: () => state.request,
providers: async (sessionID) => {
loads.push(sessionID)
if (state.loadFails) throw new Error("offline")
return options
},
reply: async (input) => {
replies.push(input)
if (state.replyFails) throw new Error("offline")
setState("request", undefined)
},
events: {
listen(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
})
return {
model,
state,
setState,
replies,
loads,
dispose,
listeners,
create(form = provider) {
setState("request", form)
listeners.forEach((listener) =>
listener({
id: "evt_create",
created: 0,
type: "form.created",
data: { form },
}),
)
},
cancel(id: string) {
setState("request", undefined)
listeners.forEach((listener) =>
listener({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
)
},
}
})
}
describe("web search request state", () => {
test("loads providers for the form owner, not the viewed parent, and waits for consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
expect(input.loads).toEqual(["ses_child"])
expect(input.model.selected()).toBe("random")
input.model.select("exa")
expect(input.replies).toEqual([])
await input.model.submit(false)
expect(input.replies[0]?.answer).toEqual({ choice: "disable" })
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
})
test("does not load providers when there is no consent request", () => {
const input = fixture(null)
expect(input.model.request()).toBeUndefined()
expect(input.loads).toEqual([])
})
test("holds the card across both forms and prevents duplicate submissions", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
await input.model.submit("parallel")
expect(input.state.request).toBeUndefined()
expect(input.model.request()?.id).toBe(consent.id)
expect(input.model.sending()).toBe(true)
input.create()
await pending
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }, { provider: "parallel" }])
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
})
test("direct provider requests need confirmation and do not load a different provider list", async () => {
const input = fixture(provider)
await ready(() => !input.model.loading())
expect(input.loads).toEqual([])
expect(input.model.specific()).toBe(true)
expect(input.model.options()).toEqual(options)
expect(input.replies).toEqual([])
await input.model.submit("exa")
expect(input.replies.map((reply) => reply.answer)).toEqual([{ provider: "exa" }])
})
test("keeps the selected provider after failure and retries only the provider form", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
input.setState("replyFails", true)
input.create()
await pending
expect(input.model.failed()).toBe(true)
expect(input.model.sending()).toBe(false)
expect(input.model.request()?.id).toBe(provider.id)
expect(input.model.selected()).toBe("parallel")
input.setState("replyFails", false)
await input.model.submit("parallel")
expect(input.replies.map((reply) => reply.answer)).toEqual([
{ choice: "choose" },
{ provider: "parallel" },
{ provider: "parallel" },
])
expect(input.model.request()).toBeUndefined()
})
test("can retry loading providers without submitting consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.setState("loadFails", true)
input.model.retry()
await ready(() => input.model.loadFailed())
expect(input.model.options()).toEqual([])
input.setState("loadFails", false)
input.model.retry()
await ready(() => !input.model.loading())
expect(input.model.options()).toEqual(options)
expect(input.model.loadFailed()).toBe(false)
expect(input.replies).toEqual([])
})
test.each(["navigate", "disconnect", "dispose", "cancel"])("stops automatic replies on %s", async (action) => {
const input = fixture()
await ready(() => !input.model.loading())
const pending = input.model.submit("exa")
if (action === "navigate") input.setState("owner", "ses_other")
if (action === "disconnect") input.setState("connected", false)
if (action === "dispose") input.dispose()
if (action === "cancel") input.cancel(consent.id)
await pending
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
input.create()
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }])
})
})
@@ -17,8 +17,8 @@ import {
} from "./shared"
const integrationPriority = new Map([
["opencode", 0],
["opencode-go", 1],
["opencode-go", 0],
["opencode", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
@@ -18,7 +18,9 @@ export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (cl
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) => response.data),
Effect.map((response) =>
response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")),
),
)
})
+30
View File
@@ -68,6 +68,36 @@ describe("auth command", () => {
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
})
test("lists OpenCode Go before Zen and preserves the remaining integration order", async () => {
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located(
[
{ id: "opencode", name: "OpenCode Zen" },
{ id: "anthropic", name: "Anthropic" },
{ id: "opencode-go", name: "OpenCode Go" },
].map((integration) => ({
...integration,
methods: [],
connections: [{ type: "env", name: "TEST_API_KEY" }],
})),
),
)
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "list", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(
result.stdout
.trim()
.split("\n")
.map((line) => line.split(/\s{2,}/)[0]),
).toEqual(["OpenCode Go", "OpenCode Zen", "Anthropic"])
})
test("runs command authentication without interactive input", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
+1
View File
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
export type { PermissionCreateInput } from "./generated/types.js"
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
+1 -1
View File
@@ -9,7 +9,7 @@ export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
@@ -278,6 +278,8 @@ export interface ClientOptions {
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: RequestInit["headers"]
/** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */
readonly onActivity?: () => void
}
interface RequestDescriptor {
@@ -369,6 +371,7 @@ export function make(options: ClientOptions) {
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (!next.done) requestOptions?.onActivity?.()
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
+15 -3
View File
@@ -1,10 +1,19 @@
export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
export type SubscribeOptions = {
readonly signal?: AbortSignal
/** Reports transport activity on the shared stream, including keepalive frames that carry no event. */
readonly onActivity?: () => void
}
export function make<A extends { readonly type: string }>(
connect: (signal: AbortSignal, onActivity: () => void) => AsyncIterable<A>,
) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => void
finish: (completion: Completion) => void
activity?: () => void
}
type Connection = {
controller: AbortController
@@ -26,7 +35,9 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
iterator = connect(connection.controller.signal, () => {
connection.subscribers.forEach((subscriber) => subscriber.activity?.())
})[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
@@ -47,7 +58,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
return {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
subscribe(options?: SubscribeOptions): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
@@ -72,6 +83,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
const subscriber: Subscriber = {
activity: options?.onActivity,
finish(result) {
finish(result, false)
},
+76 -17
View File
@@ -1,4 +1,4 @@
import { batch, onCleanup, onMount } from "solid-js"
import { batch, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
@@ -18,6 +18,11 @@ export type ClientConnectionOptions = {
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
/**
* Abort and reconnect a stream that receives no bytes for this long. The server writes a keepalive
* comment every 15 seconds, so a quiet but healthy stream never trips this.
*/
readonly idleTimeout?: number
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
@@ -27,10 +32,15 @@ export type ClientConnectionOptions = {
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
export const defaultIdleTimeout = 45_000
// Longer than one server keepalive interval: a stream that is silent this long when the page
// returns to the foreground is probably half-open after the device slept.
export const foregroundIdleThreshold = 20_000
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const idleTimeout = options.idleTimeout ?? defaultIdleTimeout
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
@@ -40,9 +50,12 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let current: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
let lastActivity = 0
let forced = false
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
@@ -63,14 +76,25 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
current = request
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
// Any received bytes, including keepalive comments, push the stall deadline out. A timer whose
// deadline passed while the page was suspended fires as soon as the page resumes.
let watchdog: ReturnType<typeof setTimeout> | undefined
const touch = () => {
lastActivity = Date.now()
if (connectedAt === undefined) return
clearTimeout(watchdog)
watchdog = setTimeout(() => request.abort(new Error("Event stream stalled")), idleTimeout)
}
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const iterator = api.event.subscribe({ signal: request.signal, onActivity: touch })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
@@ -85,6 +109,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
touch()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
@@ -92,7 +117,13 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if (event.done)
return {
error:
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
touch()
if ("durable" in event.value && event.value.durable)
options.log?.debug?.("event", {
type: event.value.type,
@@ -106,7 +137,9 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
return { error, connectedAt }
} finally {
request.abort()
if (current === request) current = undefined
clearTimeout(timeout)
clearTimeout(watchdog)
signal.removeEventListener("abort", cancel)
}
}
@@ -140,6 +173,11 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (attempt === 1) continue
}
}
// A deliberate resync already knows the old socket is gone; reconnect without backing off.
if (forced) {
forced = false
continue
}
await wait(reconnectDelay, controller.signal)
}
}
@@ -147,6 +185,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
function start() {
if (started) return run
started = true
forced = false
const active = ++generation
const previous = run
const current = (async () => {
@@ -161,26 +200,45 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
}
function stop() {
if (!started) return
started = false
generation += 1
stream?.abort()
// Nothing is listening once stopped, so consumers must treat their data as stale until start() reconnects.
setConnection({ status: "connecting", attempt: 0, error: undefined })
}
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
function resync(reason: string) {
if (!started || connection.status !== "connected") return
options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity })
forced = true
current?.abort(new Error(reason))
}
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = () => void start()
// Locking a phone or switching apps hides the document without a pagehide; the socket usually
// dies while the page is suspended, and the browser may never report that on the hung read.
const visibility = () => {
if (document.visibilityState !== "visible") return
if (Date.now() - lastActivity < foregroundIdleThreshold) return
resync("Page returned to the foreground after the event stream went quiet")
}
void start()
})
const online = () => resync("Network connection restored")
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
window.addEventListener("online", online)
document.addEventListener("visibilitychange", visibility)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
window.removeEventListener("online", online)
document.removeEventListener("visibilitychange", visibility)
})
}
void start()
onCleanup(() => {
stop()
@@ -195,6 +253,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
error: () => connection.error,
internal: {
history: () => history.slice(),
resync,
},
}
}
+30 -7
View File
@@ -73,6 +73,8 @@ export type CreateDataInput = {
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const messagePageLimit = 20
// Trailing window for event bursts that each ask for the same refetch.
export const settleMs = 150
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
@@ -141,12 +143,18 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) {
}
function createSync() {
type Pending = { promise: Promise<void>; invalidated: boolean }
// `started` is false while a reload waits for the load it replaces. Invalidations that land in
// that window are already covered, since the reload has not read anything yet.
type Pending = { promise: Promise<void>; invalidated: boolean; started: boolean }
const state = new Map<string, true | Pending>()
const start = (key: string, load: () => Promise<void>, wait?: Promise<void>) => {
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
const entry: Pending = { promise: Promise.resolve(), invalidated: false, started: !wait }
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
const run = () => {
entry.started = true
return load()
}
entry.promise = (wait ? wait.catch(() => undefined).then(run) : run())
.then(() => {
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
})
@@ -178,12 +186,12 @@ function createSync() {
if (key) {
const active = state.get(key)
if (active === true) state.delete(key)
if (active !== undefined && active !== true) active.invalidated = true
if (active !== undefined && active !== true && active.started) active.invalidated = true
return
}
state.forEach((active, current) => {
if (active === true) state.delete(current)
if (active !== true) active.invalidated = true
if (active !== true && active.started) active.invalidated = true
})
},
}
@@ -203,6 +211,20 @@ export function createData(config: CreateDataInput) {
})
}
// Runs `load` once a burst of same-key events goes quiet, so N events cost one refetch.
const settling = new Map<string, ReturnType<typeof setTimeout>>()
onCleanup(() => settling.forEach((timer) => clearTimeout(timer)))
function settle(key: string, load: () => Promise<unknown>) {
clearTimeout(settling.get(key))
settling.set(
key,
setTimeout(() => {
settling.delete(key)
refresh(load)
}, settleMs),
)
}
const [store, setStore] = createStore<Store>({
session: {
info: {},
@@ -1226,10 +1248,11 @@ export function createData(config: CreateDataInput) {
refresh(() => result.location.websearch.refresh(location))
break
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
// so the mcp list syncs here rather than off integration.updated.
// so the mcp list syncs here rather than off integration.updated. The server emits one event
// per MCP server as each settles, so a location booting nine servers emitted nine refetches.
case "mcp.status.changed":
result.location.mcp.server.invalidate(location)
refresh(() => result.location.mcp.server.sync(location))
settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location))
break
case "mcp.resources.changed":
result.location.mcp.resource.invalidate(location)
+25
View File
@@ -675,6 +675,31 @@ test("event.subscribe ignores server heartbeat comments", async () => {
expect(received).toEqual([event])
})
test("event.subscribe reports heartbeat comments as stream activity", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const encoder = new TextEncoder()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
let activity = 0
const received = []
for await (const item of client.event.subscribe({ onActivity: () => activity++ })) received.push(item)
expect(received).toEqual([event])
expect(activity).toBe(3)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
@@ -359,3 +359,24 @@ test("synchronous source creation failures reject subscribers without automatic
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
test("source activity fans out to every subscriber that asked for it", async () => {
const events = source()
let activity: (() => void) | undefined
const shared = SharedEvents.make<Event>((signal, onActivity) => {
activity = onActivity
return events.connect(signal)
})
const counts = { first: 0, second: 0 }
const first = shared.subscribe({ onActivity: () => counts.first++ })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = Promise.all([first.next(), second.next()])
activity!()
activity!()
events.connections[0].push({ type: "server.connected" })
await reads
expect(counts).toEqual({ first: 2, second: 0 })
await first.return!()
await second.return!()
await events.connections[0].closed
})
@@ -0,0 +1,153 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createClientConnection } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"
const connected = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
// One fake server whose event streams stay open until the test writes to them or the client aborts.
function server() {
const encoder = new TextEncoder()
const streams: {
write: (text: string) => void
close: () => void
aborted: boolean
}[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
let controller!: ReadableStreamDefaultController<Uint8Array>
const entry = {
write: (text: string) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
aborted: false,
}
const body = new ReadableStream<Uint8Array>({
start(value) {
controller = value
},
cancel() {
entry.aborted = true
},
})
request.signal.addEventListener("abort", () => {
entry.aborted = true
controller.error(request.signal.reason)
})
streams.push(entry)
return new Response(body, { headers: { "content-type": "text/event-stream" } })
},
})
return { api, streams }
}
function setup(input: ReturnType<typeof server>, idleTimeout: number) {
const events: OpenCodeEvent[] = []
return createRoot((dispose) => ({
events,
dispose,
connection: createClientConnection(input.api, {
idleTimeout,
flushInterval: 0,
onEvent: (event) => events.push(event),
}),
}))
}
async function until(check: () => boolean, timeout = 2_000) {
const deadline = Date.now() + timeout
while (!check()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for condition")
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
test("a stream that goes silent past the idle timeout is replaced", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
await until(() => fake.streams.length === 2)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Event stream stalled",
})
fake.streams[1].write(`data: ${JSON.stringify({ ...connected, id: "evt_connected_2" })}\n\n`)
await until(() => ctx.connection.status() === "connected" && ctx.events.length === 2)
expect(ctx.connection.error()).toBeUndefined()
} finally {
ctx.dispose()
}
})
test("keepalive comments hold a quiet stream open", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const heartbeat = setInterval(() => fake.streams[0].write(": heartbeat\n\n"), 20)
await new Promise((resolve) => setTimeout(resolve, 250))
clearInterval(heartbeat)
expect(fake.streams).toHaveLength(1)
expect(fake.streams[0].aborted).toBe(false)
expect(ctx.connection.status()).toBe("connected")
expect(ctx.events).toHaveLength(1)
} finally {
ctx.dispose()
}
})
test("a forced resync replaces the stream immediately and only while connected", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
ctx.connection.internal.resync("too early")
await until(() => fake.streams.length === 1)
expect(fake.streams[0].aborted).toBe(false)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const started = Date.now()
ctx.connection.internal.resync("Network connection restored")
await until(() => fake.streams.length === 2)
expect(Date.now() - started).toBeLessThan(500)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Network connection restored",
})
fake.streams[1].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
} finally {
ctx.dispose()
}
})
test("a stream the server closes reconnects and reports the disconnect", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
fake.streams[0].close()
await until(() => ctx.connection.status() === "reconnecting")
expect(ctx.connection.error()).toBe("Event stream disconnected")
await until(() => fake.streams.length === 2)
} finally {
ctx.dispose()
}
})
+84 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { createData, settleMs, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("config reads and update refreshes are opt-in", async () => {
@@ -165,3 +165,86 @@ test.each(["reconnecting", "disposed"] as const)("background reads respect %s ow
expect(state.requests).toBe(1)
expect(errors).toEqual([])
})
test("a burst of mcp.status.changed events refetches the server list once it settles", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const location = { directory: "/project" }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(new URL(request.url).pathname)
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.location.mcp.server.sync()
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
for (const server of ["a", "b", "c", "d", "e"]) {
const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } }
listeners.forEach((listener) => listener({ name: event.type, details: event }))
}
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
await new Promise((resolve) => setTimeout(resolve, settleMs * 2))
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2)
} finally {
setup.dispose()
}
})
test("invalidations that land before a queued reload starts are absorbed by it", async () => {
const pending: Array<PromiseWithResolvers<Response>> = []
const location = { directory: "/project" }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: () => {
const deferred = Promise.withResolvers<Response>()
pending.push(deferred)
return deferred.promise
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
const first = setup.data.location.agent.sync()
expect(pending).toHaveLength(1)
setup.data.location.agent.invalidate()
const second = setup.data.location.agent.sync()
setup.data.location.agent.invalidate()
const third = setup.data.location.agent.sync()
setup.data.location.agent.invalidate()
expect(pending).toHaveLength(1)
pending[0].resolve(Response.json({ location, data: [] }))
await first
await Promise.resolve()
expect(pending).toHaveLength(2)
pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] }))
await Promise.all([second, third])
expect(pending).toHaveLength(2)
await setup.data.location.agent.sync()
expect(pending).toHaveLength(2)
} finally {
setup.dispose()
}
})

Some files were not shown because too many files have changed in this diff Show More