Compare commits

..
Author SHA1 Message Date
LukeParkerDev cb87c5a139 feat(desktop): route browser networking through plugin RPC 2026-09-04 14:06:25 +10:00
LukeParkerDev 053ffe0f15 fix(app): match browser masks to the panel backdrop 2026-09-04 14:06:24 +10:00
LukeParkerDev 38a42b444d fix(desktop): square the browser top corners 2026-09-04 14:06:23 +10:00
LukeParkerDev 38eadd44dd fix(app): make the experimental browser opt-in 2026-09-04 14:06:23 +10:00
LukeParkerDev df738e2dc7 chore(desktop): format browser source guards 2026-09-04 14:06:22 +10:00
LukeParkerDev 6221beaa38 fix(desktop): bind CPU exports to their capture sources 2026-09-04 14:06:21 +10:00
LukeParkerDev b4a6c8da3b fix(desktop): inspect dialogs without querying blocked renderers 2026-09-04 14:06:21 +10:00
LukeParkerDev 21aeff2dad fix(desktop): preserve browser target provenance and lifetime 2026-09-04 14:06:20 +10:00
LukeParkerDev 30dce5d30a fix(desktop): clarify invalid browser input and capture failures 2026-09-04 14:06:19 +10:00
LukeParkerDev 2d388ab6b3 fix(desktop): give browser failures actionable recovery steps 2026-09-04 14:06:19 +10:00
LukeParkerDev 02477c0ad1 fix(desktop): require visible tabs for screenshots 2026-09-04 14:06:18 +10:00
LukeParkerDev 1bb804fece refactor(desktop): consume plugin-owned browser contracts 2026-09-04 14:06:18 +10:00
LukeParkerDev e4c7e74d8a fix(desktop): preserve browser zoom and screenshot coordinates 2026-09-04 14:06:17 +10:00
LukeParkerDev 54323d3652 chore(app): format browser tab reconciliation 2026-09-04 14:06:17 +10:00
LukeParkerDev c6b2d4a572 feat(desktop): add tabbed browser automation and diagnostics 2026-09-04 14:06:16 +10:00
LukeParkerDev 0a7d65feef fix(session-ui): capitalize browser tool label 2026-09-04 14:06:15 +10:00
LukeParkerDev 35cfa939ad fix(app): fit the browser pane into the review card 2026-09-04 14:06:15 +10:00
LukeParkerDev 00d0413bb1 fix(desktop): bound browser registration and stop retrying unsupported servers 2026-09-04 14:06:14 +10:00
LukeParkerDev ad5a5ef5ad fix(app): keep the browser attachment and tab menu reliable 2026-09-04 14:06:13 +10:00
LukeParkerDev a902742556 fix(app): hide the browser page under floating menus 2026-09-04 14:06:13 +10:00
LukeParkerDev 3dfb3af682 feat(app): open browser inside review tabs 2026-09-04 14:06:12 +10:00
LukeParkerDev df8fc17dee test(app): include the browser settings default 2026-09-04 14:06:12 +10:00
LukeParkerDev fdee4db782 refactor(desktop): use the shared browser contract 2026-09-04 14:06:11 +10:00
LukeParkerDev c145ef8392 fix(desktop): publish acknowledged browser state 2026-09-04 14:06:10 +10:00
LukeParkerDev f01ce94044 feat(desktop): connect browser pane through plugin RPC 2026-09-04 14:06:10 +10:00
LukeParkerDev f9a628a38a feat(browser): relay network traffic through plugin RPC 2026-09-04 14:06:08 +10:00
LukeParkerDev 2859c81d9d test(browser): remove SDK-host integration tests 2026-09-04 14:06:07 +10:00
LukeParkerDev f5f0e95210 fix(browser): retain upload arguments during inspection 2026-09-04 14:06:06 +10:00
LukeParkerDev cc13d3fbe6 fix(browser): expose target inspection and attachment replacement 2026-09-04 14:06:06 +10:00
LukeParkerDev f96ce72fcf refactor(browser): separate connection and tool ownership 2026-09-04 14:06:05 +10:00
LukeParkerDev 5686be02af fix(browser): return malformed capture errors as tool failures 2026-09-04 14:06:04 +10:00
LukeParkerDev 7ec54a9f89 fix(browser): explain recovery from tool and transfer failures 2026-09-04 14:06:04 +10:00
LukeParkerDev 66a2cd2e57 docs(browser): specify screenshot visibility requirements 2026-09-04 14:06:03 +10:00
LukeParkerDev a133f525e9 refactor(browser): keep contracts inside the plugin package 2026-09-04 14:06:02 +10:00
LukeParkerDev 3cc7a3fb64 docs(browser): clarify capture and remote deployment limits 2026-09-04 14:06:02 +10:00
LukeParkerDev 966e973fb9 fix(browser): preserve transferred file names 2026-09-04 14:06:01 +10:00
LukeParkerDev 09223172a3 fix(browser): tighten wire contracts and file handling 2026-09-04 14:06:01 +10:00
LukeParkerDev 5c34b3da5e feat(browser): define tab-targeted tools and RPC file transfers 2026-09-04 14:06:00 +10:00
LukeParkerDev b2756f14f4 feat(browser): expose the browser through Code Mode 2026-09-04 14:06:00 +10:00
LukeParkerDev c037fd65a5 fix(sdk): resolve packaged worker paths on Windows 2026-09-04 14:05:59 +10:00
LukeParkerDev 0485ddab37 fix(plugin-browser): keep release helpers out of the package 2026-09-04 14:05:58 +10:00
LukeParkerDev 2a09b21000 refactor(browser): extract the plugin into its own package 2026-09-04 14:05:58 +10:00
LukeParkerDev 0613a2923c fix(browser): let a newer attachment replace a stale one 2026-09-04 14:05:57 +10:00
LukeParkerDev b601569e3b refactor(browser): use canonical public schemas 2026-09-04 14:05:57 +10:00
LukeParkerDev 5eefeee0ce refactor(browser): defer permission enforcement 2026-09-04 14:05:56 +10:00
LukeParkerDev 4d54418f14 refactor(browser): colocate the public API plugin 2026-09-04 14:05:55 +10:00
LukeParkerDev aed0f9ccf9 feat(browser): add public RPC browser plugin 2026-09-04 14:05:55 +10:00
Aiden Cline ffac1c5b11 fix(core): classify GitHub Copilot requests on every route (#47160) 2026-09-03 22:13:04 -05:00
opencode-agent[bot]andrekram1-node c5dca2df37 feat(core): suggest moving sessions into task worktrees (#47202)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 22:06:15 -05:00
Aiden Cline 8889447f5a fix(core): scope compaction Completed section to finished work (#47203) 2026-09-03 22:05:25 -05:00
opencode-agent[bot]andHona a1cb005799 fix(app): remove timeline settings advanced divider (#47196)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 12:25:33 +10:00
Kit Langton 19833ad1fd feat(simulation): expose and record real mouse input (#47194) 2026-09-04 02:17:33 +00:00
Kit Langton cb852434b1 fix(tui): preserve selection under a stationary pointer (#47138) 2026-09-04 02:04:29 +00:00
opencode-agent[bot]andiamdavidhill b3733e9517 fix(app): add bottom padding to settings pages (#47191)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 01:58:55 +00:00
opencode-agent[bot]andHona 331f4ecd2f fix(app): contain preferences scroll overflow (#47190)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 01:54:52 +00:00
Luke Parker ecaa914b79 fix(app): align Windows app icon in title bar (#47189) 2026-09-04 01:54:22 +00:00
Aiden Cline 4f8dea674a refactor(ai): drop converse tool deltas without an open block (#47182) 2026-09-03 20:50:35 -05:00
Aiden Cline 4bf5269c4c fix(core): retry transient compaction failures (#47159) 2026-09-03 20:45:47 -05:00
Kit Langton c9d240704d fix(core): settle abandoned compactions before resuming sessions (#47178) 2026-09-04 01:02:29 +00:00
Kit Langton c9df4ba80d fix(util): skip unused audits during package installs (#47176) 2026-09-04 00:58:29 +00:00
David HillandLukeParkerDev ac71a55294 fix(app): keep right panel controls aligned (#46996)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 00:54:39 +00:00
Aiden Cline f84d927e07 refactor(ai): drop reasoning items when they finish (#47100) 2026-09-03 19:53:22 -05:00
d431fedce5 test(core): disable npm audits in the test preload (#47170)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:52:18 -05:00
Kit Langton e536b9627e feat(tui): inspect live shell output (#47134) 2026-09-04 00:47:58 +00:00
David HillandLukeParkerDev 6af46cc8a9 feat(app): add settings to vertical tabs (#47119)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:45:09 +10:00
David HillandLukeParkerDev c76e602ba4 fix(app): keep session tab labels stable during creation (#47099)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:33:51 +10:00
opencode-agent[bot]andrekram1-node 726107729e test(core): align Code Mode catalog scope assertions (#47169)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:30:25 -05:00
David Hill 0808ebc3c5 fix(app): refine build indicators in tab navigation (#47112) 2026-09-04 09:57:32 +10:00
David HillandLukeParkerDev 17e5c5fbf1 feat(app): show vertical sidebar shortcut hints (#47122)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:42:29 +10:00
David Hill b0a13b810e fix(app): allow new session shortcut from settings (#47123) 2026-09-04 09:42:14 +10:00
Luke Parker 6575215ddf fix(desktop): keep command palette responsive and scoped (#47164) 2026-09-04 09:41:12 +10:00
David HillandLukeParkerDev 09157609b7 feat(app): add session project menu and polish header (#46977)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:25:31 +10:00
Luke Parker 7ba5f3e5b2 fix(core): restore Ctrl+C in Windows terminals (#47163) 2026-09-04 09:16:14 +10:00
David HillandLukeParkerDev 0ec1587948 feat(app): add about settings page (#45782)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-03 23:07:41 +00:00
David Hill 2fda2c8141 feat(app): give experimental settings a dedicated page (#46973) 2026-09-04 08:33:29 +10:00
Luke Parker f06bf47e72 fix(app): preserve slash commands when creating worktrees (#47155) 2026-09-03 22:25:42 +00:00
opencode-agent[bot]andHona fa6e7e179d fix(desktop): render before telemetry (#47102)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-04 08:21:16 +10:00
Dax Raad 4272bb83e0 feat(cli): support command aliases with update alias for upgrade 2026-09-03 18:10:01 -04:00
usrnk1andiamdavidhill 1ae2cf3567 feat(desktop): polish menus, file tabs, and empty states (#47095)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-04 07:55:27 +10:00
Dax Raad 2084c52952 fix(core): clarify code mode tool scope 2026-09-03 17:21:06 -04:00
Aiden Cline 5a9931280e feat(core): add Modal live model backfill plugin (#47136) 2026-09-03 16:03:21 -05:00
Aiden Cline b2fb2c5e36 chore(core): bump GitHub Copilot API version to 2026-08-01 (#47108) 2026-09-03 15:38:32 -05:00
202 changed files with 11480 additions and 1296 deletions
+188 -2
View File
@@ -62,6 +62,7 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -358,6 +359,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -416,6 +418,8 @@
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"lighthouse": "13.4.1",
"puppeteer-core": "25.9.0",
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
@@ -423,6 +427,8 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -433,6 +439,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"devtools-protocol": "0.0.1687809",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -605,6 +612,21 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -1824,6 +1846,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=="],
@@ -2144,6 +2176,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"],
@@ -2208,6 +2242,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=="],
@@ -2218,6 +2254,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=="],
@@ -2490,6 +2528,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=="],
@@ -2546,6 +2586,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=="],
@@ -2744,10 +2786,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=="],
@@ -3314,6 +3364,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=="],
@@ -3542,10 +3594,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=="],
@@ -3602,6 +3660,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=="],
@@ -3638,6 +3698,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=="],
@@ -3742,6 +3804,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=="],
@@ -3790,6 +3854,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=="],
@@ -3894,6 +3960,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=="],
@@ -3938,7 +4006,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=="],
@@ -4240,6 +4308,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=="],
@@ -4264,8 +4334,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=="],
@@ -4284,6 +4358,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=="],
@@ -4402,12 +4478,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=="],
@@ -4480,8 +4560,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=="],
@@ -4528,6 +4616,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=="],
@@ -4560,6 +4650,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=="],
@@ -4720,8 +4812,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=="],
@@ -5032,6 +5130,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=="],
@@ -5158,6 +5258,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=="],
@@ -5192,6 +5294,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=="],
@@ -5350,6 +5454,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=="],
@@ -5484,6 +5590,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=="],
@@ -5506,6 +5614,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=="],
@@ -5568,6 +5680,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=="],
@@ -5728,6 +5842,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=="],
@@ -5736,6 +5852,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=="],
@@ -5812,6 +5930,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=="],
@@ -6284,6 +6404,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=="],
@@ -6316,6 +6442,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=="],
@@ -6326,6 +6454,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=="],
@@ -6434,6 +6574,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=="],
@@ -6452,6 +6596,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=="],
@@ -6496,6 +6642,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=="],
@@ -6540,6 +6688,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=="],
@@ -6548,10 +6698,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=="],
@@ -6616,6 +6772,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=="],
@@ -6684,6 +6844,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=="],
@@ -7202,6 +7364,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=="],
@@ -7210,10 +7374,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=="],
@@ -7382,6 +7556,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=="],
@@ -7418,6 +7594,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=="],
@@ -7440,6 +7618,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=="],
@@ -8184,6 +8368,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=="],
+6 -14
View File
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -508,7 +505,6 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
@@ -620,16 +616,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
// A delta for a block that is not open, whether it already stopped or never
// started, has nothing to attach to and is dropped.
const result = ToolStream.append(
state.tools,
index,
event.contentBlockDelta.contentBlockIndex,
event.contentBlockDelta.delta.toolUse.input,
"Bedrock Converse tool delta is missing its tool call",
)
if (ToolStream.isError(result)) return yield* result
if (!result) return [state, []] as const
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
@@ -668,7 +662,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -765,7 +758,6 @@ export const protocol = Protocol.make({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
+11 -53
View File
@@ -433,7 +433,6 @@ export interface ParserState {
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -950,7 +949,7 @@ export const normalize = (state: ParserState, input: Event): NormalizedEvent =>
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
@@ -990,7 +989,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
@@ -1015,7 +1014,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
@@ -1074,7 +1073,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
@@ -1112,7 +1110,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
@@ -1247,7 +1245,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "reasoning") {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
@@ -1274,53 +1271,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
const reasoningItems = { ...state.reasoningItems }
delete reasoningItems[item.id]
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [state, NO_EVENTS] satisfies StepResult
+12 -6
View File
@@ -159,6 +159,17 @@ export const appendOrStart = <K extends StreamKey>(
return appendTool(tools, key, tool, delta.text)
}
/**
* Append argument text to a started tool. Returns `undefined` when no tool is
* open under `key`, for protocols that ignore deltas without a matching block.
*/
export const append = <K extends StreamKey>(tools: State<K>, key: K, text: string): AppendOutcome<K> | undefined => {
const current = tools[key]
if (!current) return undefined
if (text.length === 0) return { tools, tool: current, events: [] }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
/**
* Append argument text to a tool that must already have been started. This keeps
* protocols honest when their stream grammar promises a start event before any
@@ -170,12 +181,7 @@ export const appendExisting = <K extends StreamKey>(
key: K,
text: string,
missingToolMessage: string,
): AppendOutcome<K> | AIError => {
const current = tools[key]
if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
): AppendOutcome<K> | AIError => append(tools, key, text) ?? eventError(route, missingToolMessage)
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
@@ -713,9 +713,10 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores late tool deltas after contentBlockStop", () =>
it.effect("ignores tool deltas without an open tool block", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 5, delta: { toolUse: { input: "{}" } } }],
[
"contentBlockStart",
{
@@ -745,27 +746,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects tool deltas without contentBlockStart", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse tool delta is missing its tool call",
})
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
it.effect("closes implicit summary boundaries", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
it.effect("preserves done-only reasoning text and encryption", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
@@ -2861,7 +2861,6 @@ describe("OpenAI Responses route", () => {
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
@@ -0,0 +1,18 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
const component = await mount("ui-split-button--open-in", { globals: { theme } })
const control = component.locator('[data-component="split-button-v2"]')
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).not.toHaveCSS("box-shadow", "none")
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
await component.getByRole("button", { name: "Open options" }).hover()
await expect(control).toHaveCSS("box-shadow", border)
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("box-shadow", border)
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
})
}
@@ -0,0 +1,47 @@
import { benchmark, expect } from "./benchmark"
import { openCommandPalette } from "../utils/command-palette"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const home of [false, true]) {
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
const { dialog, input } = await openCommandPalette(page, home)
const title = home ? "Open settings" : "Copy Session ID"
const query = home ? "open settings" : "copy session"
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
await input.evaluate((element, title) => {
element.addEventListener(
"input",
() => {
performance.mark("palette-input")
const observer = new MutationObserver(() => {
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
if (!selected?.textContent?.includes(title)) return
performance.measure("palette-result", "palette-input")
observer.disconnect()
})
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
},
{ once: true, capture: true },
)
}, title)
await input.fill(query)
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
"aria-selected",
"true",
)
const result = await page.evaluate(() =>
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
)
expect(result).toHaveLength(1)
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
})
}
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block" })
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const palette = await openCommandPalette(page)
const path = `**/api/session/${paletteSession.id}`
await page.route(path, (route) => route.abort("failed"))
const requested = page.waitForRequest(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_failed_refresh",
created: 2,
type: "session.viewed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, idle: 2 },
},
])
}, paletteSession.id)
await requested
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await palette.input.fill("copy session")
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await palette.input.press("Escape")
await page.unroute(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_recovered_refresh",
created: 3,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 2, version: 1 },
data: { sessionID, title: "Recovered session" },
},
])
}, paletteSession.id)
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
expect(errors).toEqual([])
})
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const { dialog, input } = await openCommandPalette(page)
const release = Promise.withResolvers<void>()
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
await release.promise
await route.fallback()
})
await input.pressSequentially("copy session")
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
await expect(copy).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
expect(warnings).toEqual([])
release.resolve()
})
test("home commands do not wait for session search", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page, true)
const release = Promise.withResolvers<void>()
await page.route("**/api/session?*", async (route) => {
await release.promise
await route.fallback()
})
await input.fill("open settings")
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(page).toHaveURL("/settings")
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
release.resolve()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", async (route) => {
await files.promise
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
})
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
},
})
})
await input.fill("copy")
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
await expect(project).toBeVisible()
// Select a non-first command with the keyboard before remote results arrive.
await input.press("ArrowDown")
await expect(project).toHaveAttribute("aria-selected", "true")
files.resolve()
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
// File results are usable even while sessions are still pending.
sessions.resolve()
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
await input.fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
})
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
},
})
})
await input.fill("README")
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
await expect(file).toHaveAttribute("aria-selected", "true")
sessions.resolve()
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
await expect(file).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
test.use({ serviceWorkers: "block", video: "off" })
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page)
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: { data: [{ path: "fixture.txt", type: "file" }] },
}),
)
await palette.input.fill("fixture.txt")
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
const file = page.getByRole("tab", { name: /fixture\.txt/ })
await expect(file).toBeVisible()
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("complementary", { name: "Review and files" })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(file).toHaveCount(0)
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
expect(warnings).toEqual([])
})
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page, true)
await palette.input.press("Escape")
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("region", { name: "Recent sessions" })
.getByRole("button", { name: /Palette fixture session/ })
.click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.keyboard.press("ControlOrMeta+t")
await expect(page).toHaveURL(/\/new-session\?/)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.locator('[data-component="composer-editor"]').blur()
await page.keyboard.press("Control+l")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeFocused()
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
await dialog.getByRole("textbox").press("Escape")
await expect(dialog).toHaveCount(0)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
for (const count of [3, 4]) {
await page.getByRole("button", { name: "New session", exact: true }).click()
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
}
await page.setViewportSize({ width: 600, height: 800 })
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.setViewportSize({ width: 1280, height: 800 })
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.keyboard.press("ControlOrMeta+w")
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
expect(warnings).toEqual([])
})
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -25,6 +26,53 @@ for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page, { untitled: true })
const label = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tab-title"]'
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
)
await expect(label).toHaveText("Session")
const pending = await submitPending(page, mock)
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("pending-tab-title", {
body: await page.screenshot(),
contentType: "image/png",
})
await expect(label).toHaveText("Session")
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(label).toHaveText("Session")
if (viewport.name === "mobile") {
await label.click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
await tab.click()
await expect(drawer).toBeHidden()
}
mock.events.push({
id: "evt_generated_title",
type: "session.renamed",
created: Date.now(),
location: { directory: workspace },
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
data: { sessionID: pending.sessionID, title: "Generated session title" },
})
await expect(label).toHaveText("Generated session title")
})
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
@@ -335,6 +383,61 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
const events: OpenCodeEvent[] = []
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
const expanded =
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
await page.route("**/api/session/*/command", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
commands.push({ sessionID, body: route.request().postDataJSON() })
// The server owns command expansion; the client receives the expanded inbox item.
events.push({
id: "evt_workspace_review",
type: "session.inbox.enqueued",
created: Date.now(),
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
inboxID: "msg_workspace_review",
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({ status: 204, headers })
})
const editor = page.locator('[data-component="composer-editor"]')
await editor.fill("/review")
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(editor).toHaveText("/review")
const pending = await submitPending(page, mock, "/review latest commit")
await draftFollowUp(page)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect
.poll(() => commands)
.toEqual([
{
sessionID: pending.sessionID,
body: { command: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
},
])
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
await expect(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
expect(mock.prompts).toEqual([])
await testInfo.attach("expanded-worktree-command", {
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
contentType: "image/png",
})
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
@@ -346,12 +449,16 @@ async function draftFollowUp(page: Page) {
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
async function openDraft(
page: Page,
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const events: OpenCodeEvent[] = []
const project = {
id: projectID,
worktree: directory,
@@ -378,6 +485,7 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
events: options?.events ?? (() => events.splice(0)),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
@@ -404,7 +512,10 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
const session = currentSession(
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
workspace,
)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
@@ -436,6 +547,17 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
}),
)
if (options?.command) {
await page.route("**/api/command?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [{ name: "review", description: "Review changes" }],
},
headers,
}),
)
}
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@@ -461,11 +583,11 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts, events }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
await page.locator('[data-component="composer-editor"]').fill(prompt)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
@@ -481,7 +603,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
@@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { createMockServerHandler } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
test.use({ serviceWorkers: "block" })
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
await mockServers(page)
await page.addInitScript(
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
// Both servers stay connected while the client hydrates their active-session snapshots.
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
const servers = new Map(
[sessionA, sessionB].map(
(current) =>
[
current === sessionA ? serverA : serverB,
createMockServerHandler({
directory: current.directory,
project: {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions: current === sessionB ? [current, childB] : [current],
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
provider: { all: [], connected: [], default: {} },
pageMessages: () => ({ items: [] }),
}),
] as const,
),
)
page.on("close", () => servers.forEach((server) => void server.dispose()))
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const server = servers.get(url.origin)
if (!server) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
if (directory && directory !== current.directory)
return route.fulfill({
status: 500,
json: { name: "InvalidDirectory" },
headers: { "access-control-allow-origin": "*" },
})
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
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/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
const body = route.request().postDataBuffer()
const response = await server.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
"href",
"#opencode-v2-icon-plus",
)
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
@@ -0,0 +1,189 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Locator } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTogglePosition"
const sessionID = "ses_review_toggle_position"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_toggle_position",
worktree: directory,
vcs: "git",
name: "review-toggle-position",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-toggle-position",
projectID: "proj_review_toggle_position",
directory,
title: "Review toggle position",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
})
for (const width of [1000, 1440]) {
for (const direction of ["ltr", "rtl"] as const) {
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const header = page.locator("[data-session-title]")
const panel = page.locator("#review-panel")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const closed = await toggle.boundingBox()
if (!closed) throw new Error("Review toggle bounds are unavailable")
const headerBox = await header.boundingBox()
if (!headerBox) throw new Error("Session header bounds are unavailable")
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
await expect
.poll(async () => {
const box = await panel.boundingBox()
if (!box) return false
return (
closed.x >= box.x &&
closed.x + closed.width <= box.x + box.width &&
closed.y >= box.y &&
closed.y + closed.height <= box.y + 52
)
})
.toBe(true)
await expect
.poll(async () => {
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box ? box.y + box.height / 2 : undefined
})
.toBe(closed.y + closed.height / 2)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
})
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const ptys: { id: string; title: string }[] = []
const removed: string[] = []
await page.route("**/api/pty**", async (route) => {
const path = new URL(route.request().url()).pathname
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
if (route.request().method() === "DELETE") {
removed.push(path.split("/").at(-1)!)
return route.fulfill({ status: 204 })
}
if (path.endsWith("/connect-token")) {
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
}
if (path === "/api/pty" && route.request().method() === "POST") {
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
ptys.push(pty)
return route.fulfill({ json: { location, data: pty } })
}
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
})
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Control+Backquote")
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
for (const number of [2, 3, 4]) {
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
}
await expect
.poll(async () => {
const tabs = await terminal.getByRole("tablist").boundingBox()
const button = await toggle.boundingBox()
if (!tabs || !button) return false
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
})
.toBe(true)
await expectTerminalControlsAligned(terminal, toggle)
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
expect(removed).toEqual(["pty_review_4"])
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const position = await toggle.boundingBox()
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expect
.poll(async () => {
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
const button = await toggle.boundingBox()
if (!actions || !button) return undefined
return actions.y + actions.height / 2 - (button.y + button.height / 2)
})
.toBe(0)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expectTerminalControlsAligned(terminal, toggle)
})
}
}
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
await expect
.poll(async () => {
const centers = await Promise.all(
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
button.locator("svg").evaluate((element) => {
const svg = element as SVGSVGElement
const path = svg.getBBox()
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
.y
}),
),
)
return centers[0]! - centers[1]!
})
.toBeCloseTo(0, 1)
}
@@ -23,7 +23,8 @@ for (const direction of ["ltr", "rtl"] as const) {
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
@@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const boxes = await Promise.all(
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
)
const [title, menu, sidebar, summary] = boxes
if (!title || !menu || !sidebar || !summary) return false
return direction === "ltr"
? Math.abs(title.x + title.width - menu.x) <= 1 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
.poll(async () => {
const boxes = await Promise.all(
[project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) =>
control.boundingBox(),
),
)
const [icon, title, menu, sidebar, summary] = boxes
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
@@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toHaveAttribute("aria-expanded", "false")
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
const [button, menu] = await Promise.all([
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
}),
)
.toBeCloseTo(32, 0)
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
Math.abs(divider.right - menu.right) <= 0.5 &&
rows.every(
(row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5,
)
)
}),
)
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
@@ -0,0 +1,327 @@
import { expect, test } from "@playwright/test"
import { dict } from "../../src/runtime/i18n/ar"
import en from "../../src/runtime/i18n/en"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ serviceWorkers: "block" })
for (const direction of ["ltr", "rtl"] as const) {
for (const workspace of [false, true]) {
test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => {
const copy = direction === "rtl" ? dict : en
const directory = workspace
? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip"
: fixture.directory
const project = {
...fixture.project,
name: workspace
? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip"
: "Timeline project",
sandboxes: workspace ? [directory] : [],
icon: {
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7" fill="blue"/></svg>')}`,
},
}
await mockOpenCodeServer(page, {
directory,
project,
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem(
"opencode.global.dat:language",
JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }),
)
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }),
)
}, direction)
await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 })
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const trigger = header.getByRole("button", { name: project.name, exact: true })
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
await expect(page.getByRole("tooltip")).toHaveText(project.name)
await trigger.click()
const menu = page.getByRole("menu", { name: project.name, exact: true })
const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true })
const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("tooltip")).toBeHidden()
await expect(menu.getByText(project.name, { exact: true })).toBeVisible()
await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url)
await expect(menu.getByText(directory, { exact: true })).toBeVisible()
await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr")
await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1)
await expect(menu).toHaveCSS("direction", direction)
await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"])
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await expect(settings).toBeEnabled()
await expect
.poll(() => menu.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(320)
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
await expect(label).toHaveCSS("text-overflow", "ellipsis")
await expect(label).toHaveCSS("white-space", "nowrap")
if (workspace) {
await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
}
}
await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const icons = menu.locator(
'[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)',
)
await expect(icons).toHaveCount(3)
await expect
.poll(async () => {
const [button, centers] = await Promise.all([
trigger.boundingBox(),
icons.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect()
return box.x + box.width / 2
}),
),
])
return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1)
})
.toBe(true)
if (!workspace) await page.clock.install()
for (const text of [project.name, directory]) {
const label = menu.getByText(text, { exact: true })
const item = menu.getByRole("menuitem", { name: text, exact: true })
const anchor = item.locator("..")
const openIcon = item.locator('[data-slot="session-project-open-icon"]')
const content = item.locator(".session-project-link-content")
const width = await label.evaluate((element) => element.getBoundingClientRect().width)
await expect(openIcon).toHaveCount(text === directory ? 1 : 0)
await anchor.hover()
await expect(content).toHaveCSS("mask-image", "none")
await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
if (text === directory) {
await expect(openIcon).toHaveCSS("opacity", "0")
await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right")
await expect
.poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect
.poll(async () => {
const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()])
if (!row || !icon) return false
return (
Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 &&
Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5
)
})
.toBe(true)
}
await expect(label).toHaveCSS("cursor", "default")
await expect(anchor).toHaveCSS("cursor", "default")
const tooltip = page.getByRole("tooltip")
if (workspace) {
await expect(tooltip).toHaveText(text)
await expect(tooltip).toHaveCSS("white-space", "normal")
await expect
.poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width))
.toBeLessThanOrEqual(480)
await expect
.poll(() =>
tooltip
.getByText(text, { exact: true })
.evaluate(
(element) =>
element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight),
),
)
.toBe(true)
await expect
.poll(async () => {
const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()])
return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1
})
.toBe(true)
}
if (!workspace) {
await page.clock.runFor(500)
await expect(tooltip).toBeHidden()
}
await settings.hover()
await expect(tooltip).toBeHidden()
if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0")
await expect(content).toHaveCSS("mask-image", "none")
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
const pathItem = menu.getByRole("menuitem", { name: directory, exact: true })
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("Enter")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Space")
await expect(menu).toBeVisible()
await expect(pathItem).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.press("ArrowDown")
await expect(projectItem).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(pathItem).toBeFocused()
if (workspace) await expect(page.getByRole("tooltip")).toHaveText(directory)
await page.keyboard.press("ArrowDown")
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
for (const selected of [false, true]) {
if (selected) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
}
await trigger.click()
await expect(projectItem).toBeEnabled()
const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor)
await projectItem.hover()
await expect(projectItem).not.toHaveCSS("background-color", background)
await projectItem.click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
await expect(menu).toBeHidden()
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
}
})
}
}
for (const state of ["closed", "unopened"] as const) {
test(`session project menu restores ${state} projects before and after messages load`, async ({ page }) => {
const directory = "C:/OpenCode/Worktrees/project-menu-recovery"
const messages = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory,
project: { ...fixture.project, sandboxes: [directory] },
sessions: fixture.sessions.map((session) => ({ ...session, directory })),
provider: fixture.provider,
pageMessages,
beforeMessagesResponse: (input) => (input.sessionID === fixture.targetID ? messages.promise : Promise.resolve()),
})
await page.setViewportSize({ width: 1440, height: 900 })
if (state === "closed") {
await installStressSessionTabs(page)
await page.goto("/")
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeEnabled()
await projectRow.locator("..").getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(projectRow).toHaveCount(0)
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
}
if (state === "unopened") await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: fixture.project.name, exact: true })
const menu = page.getByRole("menu", { name: fixture.project.name, exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
for (const loaded of [false, true]) {
if (loaded) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
await expect(page).toHaveURL(new URL("/", page.url()).href)
const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: fixture.project.name })
await expect(projectRow).toBeVisible()
await expect(projectRow).toHaveAttribute("data-selected", "")
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
).toBeVisible()
})
}
test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.route(
(url) => url.pathname === stressSessionHref(fixture.targetID),
async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
body: (await response.text()).replace(
'<div id="root"',
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
),
})
},
)
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
const path = page.getByRole("menu").getByRole("menuitem", { name: fixture.directory, exact: true })
const arrow = path.locator('[data-slot="session-project-open-icon"]')
await expect(arrow).toHaveCount(1)
await expect
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
})
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
})),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
})
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const binding = settings.locator('[data-keybind-id="tab.new"]')
await binding.click()
await expect(binding).toHaveText("Press keys")
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -105,6 +139,79 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("about opens without waiting for contributors", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.fulfill({
json: [],
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "Link",
Link: `<${url}&page=1004>; rel="last"`,
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByText(/^Version /)).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByText(/^v\d+\./)).toHaveCount(0)
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await settings.getByRole("tab", { name: "About", exact: true }).click()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const website = settings.getByRole("link", { name: "www.opencode.ai", exact: true })
await website.focus()
contributors.resolve()
await expect(settings.getByRole("link", { name: "988 others", exact: true })).toBeVisible()
await expect(website).toBeFocused()
})
test("about is available in the mobile settings menu", async ({ page }) => {
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) => route.abort("failed"))
await page.setViewportSize({ width: 390, height: 844 })
const settings = page.getByTestId("settings-screen")
await settings.getByRole("button", { name: "Preferences", exact: true }).click()
await page.getByRole("menuitemradio", { name: "About", exact: true }).click()
await expect(settings.getByRole("button", { name: "About", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await settings.getByRole("button", { name: "About", exact: true }).click()
await expect(page.getByRole("menuitemradio", { name: "About", exact: true })).toBeChecked()
})
test("about keeps its fallback when the contributor request fails", async ({ page }) => {
const contributors = Promise.withResolvers<void>()
const url = "https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1"
await page.route(url, async (route) => {
await contributors.promise
await route.abort("failed")
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(url)
await settings.getByRole("tab", { name: "About", exact: true }).click()
await requested
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
const failed = page.waitForEvent("requestfailed", (request) => request.url() === url)
contributors.resolve()
await failed
await expect(settings.getByRole("link", { name: "935 others", exact: true })).toBeVisible()
await expect(settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "About", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/tmp/settings-padding"
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_padding",
canonical: directory,
name: "Settings padding",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("https://api.github.com/repos/anomalyco/opencode/contributors?*", (route) =>
route.fulfill({ json: [] }),
)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeFocused()
})
for (const viewport of [
{ width: 1280, height: 720, bottom: false },
{ width: 900, height: 600, bottom: false },
{ width: 780, height: 600, bottom: false },
{ width: 390, height: 844, bottom: false },
{ width: 390, height: 844, bottom: true },
]) {
test.describe(`${viewport.width}px, ${viewport.bottom ? "bottom" : "top"} navigation`, () => {
test.use({ viewport: { width: 1280, height: 720 }, contextOptions: { reducedMotion: "reduce" } })
test("every settings page leaves room below its final content", async ({ page }) => {
await page.setViewportSize(viewport)
const settings = page.getByTestId("settings-screen")
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
if (viewport.bottom) {
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
await toggle.locator('[data-slot="switch-control"]').click()
await expect(toggle.getByRole("switch")).toBeChecked()
}
let current = "Preferences"
for (const name of [
"Preferences",
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Experimental",
"About",
]) {
if (viewport.width >= 816) await settings.getByRole("tab", { name, exact: true }).click()
if (viewport.width < 816) {
await settings.getByRole("button", { name: current, exact: true }).click()
await page.getByRole("menuitemradio", { name, exact: true }).click()
}
current = name
if (name === "About") await expect(panel.getByText("Released under the MIT License")).toBeVisible()
if (name !== "About") {
await expect(
panel.getByRole("heading", { name: name === "Shortcuts" ? "Keyboard shortcuts" : name, exact: true }),
).toBeVisible()
}
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThanOrEqual(1)
await expect
.poll(
() =>
panel.evaluate((el) => {
const body = el.querySelector(".settings-tab-body, .settings-about-content")!
return el.getBoundingClientRect().bottom - body.lastElementChild!.getBoundingClientRect().bottom
}),
{ message: `${name} bottom clearance` },
)
.toBeGreaterThanOrEqual(viewport.bottom ? 119.5 : 79.5)
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeLessThanOrEqual(1)
}
})
})
}
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ contextOptions: { reducedMotion: "reduce" }, colorScheme: "dark" })
for (const viewport of [
{ width: 1280, height: 720 },
{ width: 900, height: 600 },
{ width: 780, height: 600 },
{ width: 390, height: 844 },
]) {
test(`preferences scroll only inside the panel at ${viewport.width}x${viewport.height}`, async ({ page }) => {
const directory = "/tmp/settings-scroll"
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_scroll",
canonical: directory,
name: "Settings scroll",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
const panel = settings.getByRole("tabpanel")
const main = page.getByRole("main")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(settings).toBeFocused()
await page.setViewportSize(viewport)
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
// Wheel over the outer gutter must not move the entire settings screen.
await main.hover({ position: { x: 1, y: 200 } })
await page.mouse.wheel(0, 10000)
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect.poll(() => panel.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
await expect(main).toHaveJSProperty("scrollTop", 0)
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
await slider.scrollIntoViewIfNeeded()
await slider.click()
await page.keyboard.press("Home")
await expect(slider).toHaveValue("0")
await page.keyboard.press("ArrowRight")
await expect(slider).toHaveValue("1")
await expect(slider).toBeFocused()
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await expect(
settings.getByRole("group", { name: "Set placement and details for each activity category.", exact: true }),
).toBeVisible()
await panel.hover()
await page.mouse.wheel(0, 10000)
await expect
.poll(() => panel.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThanOrEqual(1)
await expect(main).toHaveJSProperty("scrollTop", 0)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeInViewport()
})
}
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import pkg from "../../package.json" with { type: "json" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
@@ -185,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
await expect(
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
@@ -231,7 +232,163 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
test("appearance experimental settings control vertical tab details", async ({ page }) => {
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling 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(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
...Array.from({ length: 24 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_scroll_${index}`,
})),
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", 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 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
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
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()
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}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(tabB).toBeInViewport({ ratio: 1 })
await expect
.poll(async () => {
const tab = await tabB.boundingBox()
const viewport = await scroll.boundingBox()
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
}
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()
})
}
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, locale }) => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id, locale: profile.locale },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
await expect(sidebar).toHaveCSS("direction", profile.direction)
for (const row of [
{ action: "home", shortcut: "Ctrl+Alt+H" },
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
]) {
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
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 button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect
.poll(() =>
hint.evaluate((element) => {
const button = element.closest("button")!
const row = button.getBoundingClientRect()
const hint = element.getBoundingClientRect()
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
}),
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
})
}
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -249,11 +406,14 @@ test("appearance experimental settings control vertical tab details", async ({ p
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toHaveCount(0)
await expect(settings.getByRole("tablist").getByText(/^v\d+\./)).toHaveCount(0)
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.locator('[data-action="settings-tab-layout"]')).toHaveCount(0)
await expect(settings.getByRole("switch", { name: "Show project names", exact: true })).toHaveCount(0)
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
@@ -274,19 +434,27 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Experimental", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Appearance", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
await expect(layout).toHaveCount(0)
await settings.getByRole("button", { name: "Appearance", exact: true }).click()
await page.getByRole("menuitemradio", { name: "Experimental", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Experimental", level: 2, exact: true })).toBeVisible()
await expect(layout).toContainText("Vertical")
await expect(projectNameSwitch).toBeChecked()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Experimental", exact: true })).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
@@ -310,7 +478,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Appearance" }).click()
await settings.getByRole("tab", { name: "Experimental", exact: true }).click()
await expect(layout).toContainText("Vertical")
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+57
View File
@@ -0,0 +1,57 @@
import { expect, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "./mock-server"
import { APP_READY_TIMEOUT } from "./waits"
export const paletteSession = {
id: "ses_command_palette",
projectID: "proj_command_palette",
directory: "C:/OpenCode/CommandPalette",
title: "Palette fixture session",
time: { created: 1700000000000, updated: 1700000000000 },
}
export function captureConsoleWarnings(page: Page) {
const warnings: string[] = []
page.on("console", (message) => {
if (message.type() !== "warning" && message.type() !== "error") return
// This message comes from test isolation, not application code.
if (message.text() === "Service Worker registration blocked by Playwright") return
warnings.push(message.text())
})
return warnings
}
export async function openCommandPalette(page: Page, home = false) {
await mockOpenCodeServer(page, {
directory: paletteSession.directory,
project: {
id: paletteSession.projectID,
worktree: paletteSession.directory,
vcs: "git",
name: "command-palette",
time: paletteSession.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [paletteSession],
pageMessages: () => ({ items: [] }),
findFiles: () => [],
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
if (home) {
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
}
if (!home) {
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
}
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const input = dialog.getByRole("textbox")
await expect(input).toBeFocused()
await expect(dialog.getByRole("option")).not.toHaveCount(0)
return { dialog, input }
}
+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:*",
+1 -1
View File
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
+1
View File
@@ -251,6 +251,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
+48 -2
View File
@@ -52,10 +52,12 @@ function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -420,7 +422,6 @@ describe("Composer submission", () => {
prompt: async () => undefined,
command: async (value) => sent.resolve(value),
})
target.data.location.command.list = () => [{ name: "review", description: "Review changes", template: "" }]
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
@@ -433,7 +434,7 @@ describe("Composer submission", () => {
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
const request = await sent.promise
expect(request.files).toMatchObject([{ name: "app.ts", mention: { text: "@src/app.ts" } }])
@@ -442,6 +443,51 @@ describe("Composer submission", () => {
expect(request.delivery).toBe("steer")
})
test("captures commands before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/review https://github.com/example/repo/pull/1" }).capture()
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
command: async (value) => {
requests.push(value)
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
{
sessionID: target.id,
command: "review",
text: "https://github.com/example/repo/pull/1",
files: [],
agents: [],
skills: [],
delivery: "steer",
},
])
})
test("does not run an empty shell command from hidden attachments", async () => {
const state = createMemoryComposerState().capture()
state.set([
+5 -4
View File
@@ -27,6 +27,7 @@ type ComposerSubmission = {
type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -65,6 +66,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
try {
const started =
@@ -78,7 +81,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
@@ -277,12 +279,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
await session.api.shell({ sessionID: session.id, id: Event.ID.create(), command: value.text })
}
function findCommand(session: ComposerSession, text: string) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!session.data.location.command.list({ directory: session.directory })?.some((item) => item.name === command))
return
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
+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"
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
const items = (query: string) => {
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
}
onCleanup(() => {
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
items={items}
sources={[sessions]}
highlight={highlight}
select={select}
close={() => dialog.close()}
+36 -1
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",
@@ -810,8 +812,16 @@ 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",
"titlebar.channel.dev": "Dev",
"titlebar.channel.beta": "Beta",
"titlebar.toggleDebugTools": "Toggle debug tools",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -908,6 +918,25 @@ export const dict = {
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.tab.experimental": "Experimental",
"settings.experimental.description": "Try experimental features",
"settings.tab.about": "About",
"settings.about.version": "Version {{version}}",
"settings.about.devVersion": "development",
"settings.about.license": "Released under the MIT License",
"settings.about.writtenBy": "Written by",
"settings.about.illustratedBy": "Illustrated by",
"settings.about.and": "and",
"settings.about.otherContributor.one": "{{count}} other",
"settings.about.otherContributor.other": "{{count}} others",
"settings.about.firstPublished": "First published in Missouri, USA",
"settings.about.firstIllustrated": "First illustrated in London, England",
"settings.about.website": "www.opencode.ai",
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.tagline": "AI cant build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
@@ -935,6 +964,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
@@ -1013,6 +1043,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",
@@ -1202,6 +1234,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 }
@@ -115,6 +116,9 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
@@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -127,6 +130,7 @@ function createServerController(
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
@@ -137,6 +141,13 @@ function createServerController(
},
connection: sdk.connection,
directory: "",
onError(error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t),
})
},
})
const data = createDesktopData({
data: source,
+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>
)
}
@@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
tab &&
fileCommand({
id: "tab.close",
id: "file.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
keybind: settings.keybinds.get("tab.close") ?? "mod+w",
when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')),
onSelect: closeTab,
}),
].filter((v) => !!v)
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
@@ -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,14 +221,14 @@ export function SessionSidePanel(props: {
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
createEffect(() => {
if (!file.ready()) return
@@ -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,35 +412,87 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
<For each={props.browser.tabs()}>
{(tab) => (
<Tabs.Trigger
value={sessionBrowserTab(tab.id)}
id={`${browserTabID}-${tab.id}`}
aria-controls={
activeTab() === sessionBrowserTab(tab.id) ? browserTabPanelID : undefined
}
onMiddleClick={() => props.browser.close(tab.id)}
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom" gutter={10}>
<Tabs.CloseButton
onClick={() => props.browser.close(tab.id)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<span class="max-w-40 truncate">
{tab.title || language.t("session.tab.browser")}
</span>
</div>
</Tabs.Trigger>
)}
</For>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
// The tablist redirects focus entering it to the selected
// tab, which counts as focus-outside and closes the menu.
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Show when={props.browser.available()}>
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show when={reviewOpen()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</div>
</div>
@@ -469,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}
@@ -479,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))}
+1 -1
View File
@@ -19,7 +19,7 @@ export function SortableTab(props: {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
const sortable = useSortable({
get id() {
return props.tab
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useCommand } from "@/shell/commands/command"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
export function SessionReviewToggle() {
const command = useCommand()
const language = useLanguage()
const { view } = useSessionLayout()
return (
<SessionHeaderActions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: true,
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}}
/>
)
}
export type SessionHeaderActionsState = {
reviewLabel: string
@@ -1,31 +1,19 @@
import { createMemo, Show } from "solid-js"
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const { view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
return (
<>
<TitlebarRight>
@@ -35,7 +23,9 @@ export function SessionHeader() {
</Tooltip>
</Show>
</TitlebarRight>
<SessionHeaderActions state={actions()} />
<Show when={isDesktop() && !view().reviewPanel.opened()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
)
}
+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
})
+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>
)
+16 -3
View File
@@ -28,7 +28,9 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
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")
@@ -43,6 +45,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
@@ -274,10 +277,19 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
<div
class="absolute end-3 top-0 z-30 flex items-center"
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
data-slot="session-review-toggle"
>
<SessionReviewToggle />
</div>
</Show>
<div
classList={{
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
true,
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
@@ -354,7 +366,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>
@@ -408,6 +420,7 @@ export function SessionScreen(props: { session: SessionModel }) {
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
reserveReviewToggle={!screen.side.region.open()}
/>
</div>
</div>
@@ -0,0 +1,55 @@
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
[data-popper-positioner]:has(.session-project-info-tooltip),
.session-project-info-tooltip {
pointer-events: none !important;
}
.session-options-menu [data-component="menu-v2-item"] {
padding-inline-end: 30px;
}
.session-project-link {
--session-project-fade-direction: to right;
position: relative;
&:dir(rtl) {
--session-project-fade-direction: to left;
}
&[aria-disabled="true"] {
opacity: 0.5;
}
}
.session-project-link-content {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
}
.session-project-link-open {
position: absolute;
inset-inline-end: 12px;
inset-block: 0;
display: flex;
align-items: center;
color: var(--v2-icon-icon-muted);
pointer-events: none;
opacity: 0;
}
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
.session-project-link-content {
mask-image: linear-gradient(
var(--session-project-fade-direction),
#000 calc(100% - 40px),
transparent calc(100% - 24px)
);
}
.session-project-link-open {
opacity: 1;
}
}
@@ -1,18 +1,27 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
import "./session-identity-header.css"
export function SessionTitleHeader(props: ParentProps) {
return (
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
)
}
export function SessionProjectMenu(props: {
project?: Omit<LocalProject, "expanded">
directory?: string
workspace: boolean
showProjectIcon: boolean
}) {
const server = useServer()
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const layout = useLayout()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
projectTruncated: false,
pathTruncated: false,
pathFocused: false,
})
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const openPath = () => {
if (!canOpenPath() || !platform.openPath || !props.directory) return
void platform.openPath(props.directory).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
const openProjectSettings = async () => {
const current = props.project
if (!current) return
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
}
return (
<Menu
placement="bottom-start"
gutter={4}
shift={-10}
modal={false}
open={state.open}
onOpenChange={(open) => setState({ open, pathFocused: false })}
>
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
aria-label={projectName()}
data-slot="session-project-trigger"
icon={
<Show
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
</span>
}
>
<ProjectAvatar
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
</Show>
}
/>
</Tooltip>
<Menu.Portal>
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
<Tooltip
placement="top"
gutter={2}
disabled={!state.projectTruncated}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi>{projectName()}</bdi>}
>
<Menu.Item
class="min-w-0 w-full"
disabled={!props.project}
onSelect={() => {
const project = props.project
if (!project) return
server.ctx.projects.open(project.worktree)
layout.home.setSelection({ server: server.key, directory: project.worktree })
navigate("/")
}}
>
<span class="session-project-link-content">
<ProjectAvatar
class="shrink-0"
aria-hidden="true"
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("projectTruncated", element.scrollWidth > element.clientWidth),
)
}
class="min-w-0 truncate text-13-medium"
>
{projectName()}
</bdi>
</span>
</Menu.Item>
</Tooltip>
<Tooltip
placement="top"
gutter={2}
disabled={!state.pathTruncated}
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi dir="ltr">{props.directory}</bdi>}
>
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
<Menu.Item
class="session-project-link min-w-0 w-full cursor-default"
disabled={!props.directory}
aria-disabled={!canOpenPath()}
closeOnSelect={canOpenPath()}
onSelect={openPath}
onFocus={() => setState("pathFocused", true)}
onBlur={() => setState("pathFocused", false)}
onKeyDown={(event) => {
if (event.key !== "Escape") return
event.preventDefault()
event.stopPropagation()
setState({ open: false, pathFocused: false })
}}
>
<span class="session-project-link-content">
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("pathTruncated", element.scrollWidth > element.clientWidth),
)
}
dir="ltr"
class="min-w-0 truncate text-v2-text-text-muted"
>
{props.directory}
</bdi>
</span>
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
<Icon name="arrow-up-right" />
</span>
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
@@ -71,12 +247,17 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
if (props.session)
return (
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
)
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
return (
projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
)
})
const showProjectIcon = () =>
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center">
<span
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
}}
>
<Show
when={showProjectIcon()}
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
>
<ProjectAvatar
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
src={getProjectAvatarSource(project()?.id, project()?.icon)}
variant={getProjectAvatarVariant(project()?.icon?.color)}
/>
</Show>
</span>
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionProjectMenu
project={project()}
directory={directory()}
workspace={workspaceSession()}
showProjectIcon={showProjectIcon()}
/>
<Show when={parentTitle()}>
{(value) => (
<button
@@ -141,7 +310,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
+51 -40
View File
@@ -45,6 +45,7 @@ export function TerminalPanel(
contentHeight?: string
embedded?: boolean
animate?: boolean
reserveReviewToggle?: boolean
} = {},
) {
const terminal = useTerminal()
@@ -249,7 +250,10 @@ export function TerminalPanel(
when={terminal.ready() || store.surfaces.length > 0}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<div
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
classList={{ "pe-12": props.reserveReviewToggle }}
>
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -291,46 +295,53 @@ export function TerminalPanel(
}}
>
<div class="flex flex-col h-full">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-[52px] !flex-none"
>
<Tabs.List
ref={tabList}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-full min-w-0 !flex-1"
>
<For each={all()}>
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
<Tabs.List
ref={tabList}
class="!border-b-0"
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}>
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
<Show when={props.reserveReviewToggle}>
<div class="w-12 shrink-0" aria-hidden />
</Show>
</div>
<div class="flex-1 min-h-0 relative">
<For each={store.surfaces}>
{(surface) => (
@@ -10,7 +10,6 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode-ai/util/path"
@@ -31,7 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
type BackgroundTask = {
@@ -409,10 +408,9 @@ function MessageTimelineView(
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
if (!session) return
return projectForSession(session, server.ctx.projects.list())
return projectForSession(session, server.ctx.projects.list()) ?? project()
})
const projectAvatar = () => (
<ProjectAvatar
@@ -666,36 +664,13 @@ function MessageTimelineView(
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center min-w-0 flex-1 w-full">
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
{projectAvatar()}
</Show>
</span>
}
>
<Tooltip
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": !showProjectIcon(),
}}
>
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
{projectAvatar()}
</Show>
</span>
</Tooltip>
</Show>
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
<SessionProjectMenu
project={avatarProject()}
directory={sessionDirectory()}
workspace={workspaceSession()}
showProjectIcon={showProjectIcon()}
/>
<Show when={parentID()}>
<button
type="button"
@@ -719,7 +694,7 @@ function MessageTimelineView(
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
onClick={openTitleEditor}
>
{childTitle()}
@@ -734,7 +709,7 @@ function MessageTimelineView(
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
style={{
"--inline-input-shadow": "none",
"text-align": "start",
@@ -761,7 +736,7 @@ function MessageTimelineView(
{(id) => (
<Menu
gutter={6}
placement="bottom-end"
placement="bottom-start"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
@@ -776,7 +751,8 @@ function MessageTimelineView(
/>
<Menu.Portal>
<Menu.Content
style={{ "min-width": "160px" }}
class="session-options-menu w-max"
style={{ "min-width": "0" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
+123
View File
@@ -0,0 +1,123 @@
import { For, createResource, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ExternalLink } from "@/runtime/platform/external-link"
import legal from "./legal.svg"
import anomalyBrush from "./anomaly-brush.svg"
import { AnimatedWordmark } from "./animated-wordmark"
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
const writers = [
"thdxr",
"adamdotdev",
"rekram1-node",
"kitlangton",
"iamdavidhill",
"jayair",
"fwang",
"brendonovich",
"nexxeln",
"hona",
"kommander",
"jlongster",
"vimtor",
"r44vcorp",
"simonklee",
"arvsrn",
] as const
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
export function SettingsAbout(props: { active: boolean }) {
const language = useLanguage()
const platform = usePlatform()
const [otherContributors] = createResource(
() => props.active || undefined,
() => loadOtherContributorCount(platform.fetch ?? fetch),
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
)
return (
<div class="settings-about-content">
<div class="settings-about-intro">
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
<p>{language.t("settings.about.license")}</p>
</div>
<AnimatedWordmark active={props.active} />
<div class="settings-about-credits">
<CreditLine
label={language.t("settings.about.writtenBy")}
names={writers}
and={language.t("settings.about.and")}
tail={
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
{language.plural("settings.about.otherContributor", otherContributors.latest, {
count: otherContributors.latest,
})}
</ExternalLink>
}
/>
<CreditLine
label={language.t("settings.about.illustratedBy")}
names={illustrators}
and={language.t("settings.about.and")}
/>
</div>
<div class="settings-about-publication">
<p>{language.t("settings.about.firstPublished")}</p>
<p>{language.t("settings.about.firstIllustrated")}</p>
</div>
<p class="settings-about-faint">
<ExternalLink href="https://opencode.ai">
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
</ExternalLink>
</p>
<div class="settings-about-details">
<p>{language.t("settings.about.description")}</p>
<p>{language.t("settings.about.trademark")}</p>
<p>{language.t("settings.about.typeset")}</p>
</div>
<p>{language.t("settings.about.tagline")}</p>
<img class="settings-about-legal" src={legal} alt="" />
<div class="settings-about-copyright">
<p>{language.t("settings.about.copyright")}</p>
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
</div>
</div>
)
}
function CreditLine(props: {
label: string
names: readonly string[]
and: string
tail?: JSX.Element
}) {
return (
<p>
{props.label}{" "}
<For each={props.names}>
{(name, index) => (
<>
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
<bdi dir="ltr">
<ExternalLink href={profile(name)}>{name}</ExternalLink>
</bdi>
</>
)}
</For>
{props.tail ? <>, {props.and} {props.tail}</> : null}
</p>
)
}
function profile(name: string) {
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
return `https://github.com/${name}`
}
@@ -0,0 +1,80 @@
import { createEffect, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
const choices = ["o", "p", "e", "n", "c", "d"] as const
export function AnimatedWordmark(props: { active: boolean }) {
const [state, setState] = createStore({ letters: [...target] })
const timers = new Set<ReturnType<typeof setTimeout>>()
createEffect(
on(
() => props.active,
(active) => {
timers.forEach(clearTimeout)
timers.clear()
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
setState("letters", [...target])
return
}
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
const last = Math.max(...settles)
setState("letters", starts)
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
const timer = setTimeout(() => {
setState(
"letters",
target.map((letter, index) =>
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
),
)
timers.delete(timer)
}, tick * 75)
timers.add(timer)
})
},
),
)
onCleanup(() => timers.forEach(clearTimeout))
return (
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
<defs>
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
</symbol>
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
</symbol>
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
</symbol>
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
</symbol>
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
</symbol>
</defs>
<For each={state.letters}>
{(letter, index) => (
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
)}
</For>
</svg>
)
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
describe("otherContributorCount", () => {
test("subtracts the contributors named in the colophon", () => {
expect(
otherContributorCount(
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
),
).toBe(988)
})
test("falls back when pagination metadata is unavailable", () => {
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
})
})
@@ -0,0 +1,23 @@
const CREDITED_CONTRIBUTORS = 16
let request: Promise<number> | undefined
export const FALLBACK_OTHER_CONTRIBUTORS = 935
export function otherContributorCount(link: string | null) {
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
return total - CREDITED_CONTRIBUTORS
}
export function loadOtherContributorCount(fetcher: typeof fetch) {
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}).then(
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
() => FALLBACK_OTHER_CONTRIBUTORS,
)
return request
}
@@ -0,0 +1,9 @@
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity=".5" fill="#808080">
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,7 +1,6 @@
import { Component, createMemo } from "solid-js"
import { Component } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
@@ -10,7 +9,6 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
const fontSettings = {
ui: {
action: "settings-ui-font",
@@ -128,44 +126,6 @@ export const SettingsAppearance: Component = () => {
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsList>
</div>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && appearance.tabs.select(option)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.appearance.row.projectName.title")}
description={language.t("settings.appearance.row.projectName.description")}
>
<div data-action="settings-show-project-name">
<Switch
checked={appearance.projectName.current()}
onChange={appearance.projectName.set}
hideLabel
>
{language.t("settings.appearance.row.projectName.title")}
</Switch>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
</>
)
@@ -0,0 +1,87 @@
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"
import "@/settings/settings.css"
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.experimental")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.experimental.description")}
</span>
</div>
</div>
</div>
<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")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === settings.appearance.tabLayout())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.appearance.row.projectName.title")}
description={language.t("settings.appearance.row.projectName.description")}
>
<div data-action="settings-show-project-name">
<Switch
checked={settings.appearance.showProjectName()}
onChange={settings.appearance.setShowProjectName}
hideLabel
>
{language.t("settings.appearance.row.projectName.title")}
</Switch>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
</>
)
}
@@ -81,14 +81,6 @@ export function createAppearanceSettingsController() {
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
tabs: {
current: settings.appearance.tabLayout,
select: settings.appearance.setTabLayout,
},
projectName: {
current: settings.appearance.showProjectName,
set: settings.appearance.setShowProjectName,
},
}
}
+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,
+92 -11
View File
@@ -60,7 +60,6 @@
.settings-screen .settings-tab-body {
padding-inline: 0;
padding-bottom: var(--settings-bottom-inset, 0px);
}
.settings-nav {
@@ -91,19 +90,101 @@
overflow-y: auto;
}
.settings-nav-footer {
.settings-about {
align-items: center;
padding-inline: 24px;
}
.settings-about-content {
display: flex;
flex-shrink: 0;
width: 100%;
max-width: 560px;
min-height: 462px;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-block: 20px 4px;
padding-inline-start: 4px;
font-size: 11px;
align-items: center;
justify-content: flex-start;
gap: 24px;
padding-block: 80px calc(80px + var(--settings-bottom-inset, 0px));
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-tight);
line-height: var(--line-height-base);
letter-spacing: -0.04px;
text-align: center;
}
.settings-about-content p {
margin: 0;
}
.settings-about-intro,
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
display: flex;
flex-direction: column;
}
.settings-about-intro {
gap: 14px;
}
.settings-about-credits,
.settings-about-publication,
.settings-about-details {
gap: 16px;
}
.settings-about-wordmark {
width: 178px;
height: 32px;
flex-shrink: 0;
opacity: 0.2;
color: var(--v2-text-text-muted);
fill: currentColor;
}
.settings-about-letter-shadow {
opacity: 0.3;
}
.settings-about-faint,
.settings-about-credits {
color: var(--v2-text-text-faint);
user-select: none;
}
.settings-about-legal {
width: 109.2px;
height: 19.2px;
}
.settings-about-copyright {
display: flex;
width: 100%;
flex-shrink: 0;
flex-direction: column;
align-items: center;
gap: 12px;
}
.settings-about-anomaly-brush {
width: 126.72px;
height: 48.384px;
flex-shrink: 0;
pointer-events: none;
}
.settings-about-content a {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.settings-about-content a:hover,
.settings-about-content a:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.settings-back {
@@ -192,7 +273,7 @@
flex-direction: column;
gap: 36px;
width: 100%;
padding: 0 40px 40px;
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
}
[data-slot="settings-row-description"] a.settings-link {
@@ -1089,7 +1170,7 @@
}
.settings-tab-body.settings-workspaces {
padding: 0 20px 24px;
padding-inline: 20px;
}
.settings-workspaces-toolbar,
+11 -13
View File
@@ -1,12 +1,12 @@
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsProviders } from "./providers/providers"
@@ -15,13 +15,13 @@ import { SettingsServers } from "./servers/servers"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
@@ -42,13 +42,13 @@ const sections = [
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
],
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
] as const
export const SettingsScreen: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const command = useCommand()
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
@@ -57,10 +57,8 @@ export const SettingsScreen: Component = () => {
let root: HTMLDivElement | undefined
onMount(() => {
command.keybinds(false)
root?.focus({ preventScroll: true })
})
onCleanup(() => command.keybinds(true))
const server = createMemo(() => {
const route = surface.route()
@@ -185,12 +183,6 @@ export const SettingsScreen: Component = () => {
</For>
</div>
</div>
<div class="settings-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>
<bdi dir="ltr">v{platform.version}</bdi>
</span>
</div>
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
@@ -205,6 +197,9 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
@@ -225,6 +220,9 @@ export const SettingsScreen: Component = () => {
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.tab() === "about"} />
</Tabs.Content>
</Tabs>
</div>
)
@@ -1,4 +1,5 @@
[data-component="timeline-detail-control"] {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
@@ -105,7 +106,6 @@
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
padding-top: 8px;
border-top: 0.5px solid var(--v2-border-border-base);
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
+3 -1
View File
@@ -430,7 +430,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
key: id,
options,
}
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
// Register only committed owners. Updating the registry during a transition
// can restore its pending snapshot after the outgoing owner's cleanup.
onMount(() => setStore("registrations", (arr) => addCommandRegistration(arr, entry)))
onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
})
+34 -36
View File
@@ -5,21 +5,15 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { formatKeybindParts } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useTabs } from "@/shell/tabs/tabs"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { getRelativeTime } from "@/shell/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./palette"
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
import { createCommandPaletteSearch } from "./search"
import "./dialog.css"
function groups(entries: CommandPaletteEntry[]) {
@@ -35,23 +29,24 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const loadItems = async (text: string) => {
const q = text.trim()
const items = (q: string) => {
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q))
}
return (
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
items={items}
sources={[
palette.sessions,
async (query, signal) => {
if (!query) return []
const files = await palette.file.searchFiles(query, { signal })
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
},
]}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
@@ -61,29 +56,31 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
export function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
items: (query: string) => CommandPaletteEntry[]
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [store, setStore] = createStore({ query: "", active: undefined as string | undefined })
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources })
const visibleEntries = search.items
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
// Keep keyboard selection stable when another search source adds results.
const activeEntry = createMemo(
() => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0],
)
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => {
query()
visibleEntries()
setActive(0)
// Pin automatic selection too: a later source can insert rows before it.
const id = activeEntry()?.id
if (store.active !== id) setStore("active", id)
})
createEffect(() => {
@@ -95,7 +92,8 @@ export function CommandPaletteView(props: {
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id)
setStore("active", visibleEntries()[(index + delta + count) % count].id)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
@@ -128,14 +126,14 @@ export function CommandPaletteView(props: {
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<TextInput
value={query()}
value={store.query}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={props.placeholder}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
onKeyDown={handleKeyDown}
/>
</div>
@@ -145,7 +143,7 @@ export function CommandPaletteView(props: {
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
{search.loading() ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
@@ -166,7 +164,7 @@ export function CommandPaletteView(props: {
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onActive={() => setStore("active", item.id)}
onSelect={() => props.select(item)}
/>
)}
+41
View File
@@ -0,0 +1,41 @@
import { createMemo, createResource, onCleanup } from "solid-js"
import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette"
export function createCommandPaletteSearch(props: {
query: () => string
items: (query: string) => CommandPaletteEntry[]
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
}) {
const query = createMemo(() => props.query().trim())
const local = createMemo(() => props.items(query()))
const sources = props.sources.map((load) => {
let abort: AbortController | undefined
onCleanup(() => abort?.abort())
const [result] = createResource(
query,
async (query) => {
abort?.abort()
const current = new AbortController()
abort = current
return { query, items: await load(query, current.signal).catch(() => []) }
},
// Remote searches must not suspend the dialog's local results on first render.
{ initialValue: { query: "", items: [] as CommandPaletteEntry[] } },
)
return result
})
return {
items: createMemo(() =>
uniqueCommandPaletteEntries([
...local(),
...sources.flatMap((source) => {
// Never keep results for an older query selectable while the next one loads.
const result = source.latest
return result.query === query() ? result.items : []
}),
]),
),
loading: () => sources.some((source) => source.loading),
}
}
@@ -1,5 +1,6 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast"
import type { JSX } from "solid-js"
type AppToastOptions = Omit<ToastOptions, "icon"> & {
icon?: IconProps["name"]
@@ -30,6 +31,7 @@ export function dismissToast(toastId: number) {
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
return <Icon name={name} />
if (!name) return undefined
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
return (() => <Icon name={name} />) as unknown as JSX.Element
}
+1 -1
View File
@@ -67,7 +67,7 @@ export default function Layout(props: ParentProps) {
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
}}
>
<ResizeHandle
@@ -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
+2 -2
View File
@@ -13,7 +13,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { useSettings } from "@/settings/model"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
import { TabPreviewPopover } from "./tab-popover"
@@ -63,7 +63,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session
return session ? sessionLabel(session) : props.fallbackTitle
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
})
const projectName = createMemo(() => {
@@ -171,7 +171,7 @@ function SessionTabEntry(props: {
preparing={!!pending()}
fallbackTitle={
pending()
? language.t("command.session.new")
? language.t("session.tab.session")
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
}
onRename={rename}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { sessionTabTitle } from "./tab-title"
describe("session tab titles", () => {
test("uses the same localized label before a title arrives", () => {
for (const title of [
undefined,
"",
"New session - 2026-07-30T18:45:03.662Z",
"Child session - 2026-07-30T18:45:03.662Z",
]) {
expect(sessionTabTitle(title, "Session")).toBe("Session")
expect(sessionTabTitle(title, "Sitzung")).toBe("Sitzung")
}
})
test("preserves generated and user-supplied titles", () => {
for (const title of ["Generated title", "New session", "New session - custom"]) {
expect(sessionTabTitle(title, "Session")).toBe(title)
}
})
})
@@ -0,0 +1,6 @@
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
// Draft, preparing, and untitled session tabs share one localized label.
export function sessionTabTitle(title: string | undefined, fallback: string) {
return !title || isFallbackTitle(title) ? fallback : title
}
@@ -2,6 +2,21 @@
user-select: none;
}
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs"] {
display: flex;
flex: 1;
flex-direction: column;
}
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] {
min-height: 0;
/* Leave enough space to scroll the final tab clear of the fade above Settings. */
padding-block-end: 16px;
scroll-padding-block-end: 16px;
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
}
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
background:
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
+88 -70
View File
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { Portal } from "solid-js/web"
import { Dynamic, Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -26,17 +26,20 @@ import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
import devIcon from "../../../../desktop/icons/dev/64x64.png"
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
const macTrafficLightsBaseWidth = 68
const macTrafficLightsTopClearance = 28
export type TitlebarUpdate = {
@@ -348,13 +351,19 @@ export function Titlebar(props: {
type="button"
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="mb-1 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"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 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"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
{language.t("home.title")}
<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"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
</span>
</button>
</Show>
)
@@ -413,9 +422,12 @@ export function Titlebar(props: {
const currentTitle = () => {
const tab = currentTab()
if (!tab) return language.t("home.title")
if (tab.type === "draft") return language.t("command.session.new")
if (tab.type === "draft") return language.t("session.tab.session")
const value = session()
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
return sessionTabTitle(
value ? value.title : tabs.info[tabKey(tab)]?.title,
language.t("session.tab.session"),
)
}
createEffect(() => {
path()
@@ -425,16 +437,17 @@ export function Titlebar(props: {
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
"pl-4": macTrafficLights(),
// Center the 20px app icon over the sidebar's 16px icon column.
"ps-3.5": windows(),
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} />
<ChannelIndicator horizontal debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -497,7 +510,7 @@ export function Titlebar(props: {
</span>
)}
</Show>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
<span data-slot="mobile-tab-title" dir="auto" class="min-w-0 flex-1 truncate text-start">
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
@@ -625,33 +638,30 @@ export function Titlebar(props: {
>
<Show when={macVerticalTabs()}>
<div
class="relative mb-2 w-full shrink-0"
class="mb-4 min-h-7 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
/>
</Show>
<Show when={!windows()}>
<ChannelIndicator sidebar debugTools={props.debugTools} />
</Show>
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
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"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
{language.t("command.session.new")}
<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"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
</span>
</button>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
@@ -670,14 +680,20 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
<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 />
<Show when={!macVerticalTabs() && !windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
</div>
</Portal>
)}
@@ -753,45 +769,47 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
function ChannelIndicator(props: {
horizontal?: boolean
sidebar?: boolean
debugTools?: { visible: boolean; toggle: () => void }
}) {
const language = useLanguage()
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
if (!channel || channel === "prod") return null
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
return (
<Show when={label}>
{(value) => (
<div
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
</div>
)}
</Show>
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
value={label()}
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
>
<Dynamic
component={debug() ? "button" : "div"}
type={debug() ? "button" : undefined}
data-slot="channel-indicator"
class="flex h-7 shrink-0 items-center rounded-[6px] [app-region:no-drag]"
classList={{
"w-6": props.sidebar,
"w-5": !props.sidebar,
"cursor-pointer hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02":
!!debug(),
}}
onClick={() => debug()?.toggle()}
aria-label={debug() ? language.t("titlebar.toggleDebugTools") : undefined}
aria-pressed={debug()?.visible}
>
<img
src={channel === "beta" ? betaIcon : devIcon}
alt={debug() ? "" : label()}
class="shrink-0 rounded-[4px] shadow-[var(--v2-elevation-raised)]"
classList={{ "size-6": props.sidebar, "size-5": !props.sidebar }}
draggable={false}
/>
</Dynamic>
</Tooltip>
)
}
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test"
import { createComputed, createRoot, createSignal } from "solid-js"
import type { CommandPaletteEntry } from "@/shell/commands/palette"
import { createCommandPaletteSearch } from "@/shell/commands/search"
const copy: CommandPaletteEntry = {
id: "command:session.copyID",
type: "command",
title: "Copy Session ID",
category: "Commands",
}
const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" }
const session: CommandPaletteEntry = {
id: "session:copy",
type: "session",
title: "Copy session",
category: "Sessions",
}
describe("command palette search", () => {
test("matches commands synchronously and cancels obsolete requests", () => {
const signals: AbortSignal[] = []
const root = createRoot((dispose) => {
const [query, setQuery] = createSignal("")
const search = createCommandPaletteSearch({
query,
items: (text) => (text === "copy session" ? [copy] : []),
sources: [
(_text, signal) => {
signals.push(signal)
return new Promise<CommandPaletteEntry[]>(() => {})
},
],
})
return { search, setQuery, dispose }
})
root.setQuery(" copy session ")
expect(root.search.items()).toEqual([copy])
expect(root.search.loading()).toBe(true)
expect(signals[0].aborted).toBe(true)
root.setQuery("no match")
expect(root.search.items()).toEqual([])
expect(signals[1].aborted).toBe(true)
root.dispose()
expect(signals[2].aborted).toBe(true)
})
test("publishes each source independently and drops stale results on a new query", async () => {
const files = Promise.withResolvers<CommandPaletteEntry[]>()
const sessions = Promise.withResolvers<CommandPaletteEntry[]>()
const fileVisible = Promise.withResolvers<void>()
const sessionVisible = Promise.withResolvers<void>()
const root = createRoot((dispose) => {
const [query, setQuery] = createSignal("copy")
const search = createCommandPaletteSearch({
query,
items: () => [copy],
sources: [() => sessions.promise, () => files.promise],
})
createComputed(() => {
if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve()
if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve()
})
return { search, setQuery, dispose }
})
expect(root.search.items()).toEqual([copy])
files.resolve([file])
await fileVisible.promise
expect(root.search.items()).toEqual([copy, file])
expect(root.search.loading()).toBe(true)
sessions.resolve([session])
await sessionVisible.promise
expect(root.search.items()).toEqual([copy, session, file])
expect(root.search.loading()).toBe(false)
root.setQuery("new query")
expect(root.search.items()).toEqual([copy])
root.dispose()
})
test("failed searches do not hide commands or successful sources", async () => {
const settled = Promise.withResolvers<void>()
const root = createRoot((dispose) => {
const search = createCommandPaletteSearch({
query: () => "copy",
items: () => [copy],
sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])],
})
createComputed(() => {
if (!search.loading()) settled.resolve()
})
return { search, dispose }
})
await settled.promise
expect(root.search.items()).toEqual([copy, file])
root.dispose()
})
})
+1
View File
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
aliases: ["update"],
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
+10 -2
View File
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
}
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
if (value.$) {
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
}
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
}
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
)
: node.spec
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
const children = Object.values(node.commands)
return spec.pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
Command.withSubcommands([
...children.map((child) => provide(child, handlers)),
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
]),
) as ProvidedCommand
}
+21 -5
View File
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
readonly description?: string
readonly aliases?: ReadonlyArray<string>
readonly params?: Config
readonly commands?: Commands
}
@@ -14,6 +15,7 @@ export interface Node<
readonly name: Name
readonly spec: Spec
readonly commands: Commands
readonly aliases: ReadonlyArray<Any>
}
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
@@ -24,14 +26,28 @@ export function make<
const Config extends Command.Command.Config = {},
const Commands extends ReadonlyArray<Any> = [],
>(name: Name, options: Options<Config, Commands> = {}) {
const command = Command.make(name, options.params ?? ({} as Config))
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
const aliases = options.aliases ?? []
const params = options.params ?? ({} as Config)
const command = Command.make(name, params)
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
// Effect supports a single native alias, shown inline as `name, alias` in help.
// Extra aliases become sibling commands sharing params and subcommands.
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
const commands = Object.fromEntries(
(options.commands ?? []).map((command) => [command.name, command]),
) as ChildrenOf<Commands>
const extra = aliases.slice(1).map((alias) => {
const aliasCommand = Command.make(alias, params)
const aliasSpec = options.description
? aliasCommand.pipe(Command.withDescription(options.description))
: aliasCommand
return { name: alias, spec: aliasSpec, commands, aliases: [] }
})
return {
name,
spec,
commands: Object.fromEntries(
(options.commands ?? []).map((command) => [command.name, command]),
) as ChildrenOf<Commands>,
commands,
aliases: extra,
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
})
})
+2 -2
View File
@@ -117,8 +117,8 @@ describe("mini command", () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("mini Start the minimal interactive interface")
expect(result.stdout).toContain("run Run OpenCode with a message")
expect(result.stdout).toMatch(/^ mini[ \t]+Start the minimal interactive interface\r?$/m)
expect(result.stdout).toMatch(/^ run[ \t]+Run OpenCode with a message\r?$/m)
})
test("exposes run without legacy interactive, attach, or command modes", async () => {
+81 -60
View File
@@ -66,6 +66,8 @@ export type CreateDataInput = {
readonly connection?: {
readonly status: () => "connected" | "connecting" | "reconnecting"
}
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
readonly onError?: (error: unknown) => void
}
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
@@ -187,6 +189,17 @@ function createSync() {
export function createData(config: CreateDataInput) {
const api = config.api
let disposed = false
onCleanup(() => (disposed = true))
function refresh(load: () => Promise<unknown>) {
if (disposed || (config.connection && config.connection.status() !== "connected")) return
void load().catch((error) => {
if (disposed || (config.connection && config.connection.status() !== "connected")) return
if (config.onError) return config.onError(error)
console.error("Failed to refresh client data", error)
})
}
const [store, setStore] = createStore<Store>({
session: {
@@ -554,31 +567,34 @@ export function createData(config: CreateDataInput) {
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
void api()
.session.active()
.then((active) => {
if (activeUpdates !== updates) return
// Lifecycle events received during hydration supersede the snapshot.
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
updates.forEach((status, id) => {
if (status === undefined) return snapshot.delete(id)
snapshot.set(id, status)
refresh(() =>
api()
.session.active()
.then((active) => {
if (activeUpdates !== updates) return
// Lifecycle events received during hydration supersede the snapshot.
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
updates.forEach((status, id) => {
if (status === undefined) return snapshot.delete(id)
snapshot.set(id, status)
})
activeUpdates = undefined
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
})
activeUpdates = undefined
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
})
.catch(() => {
if (activeUpdates === updates) activeUpdates = undefined
})
void api()
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { info: location })
})
.catch((error) => console.error("Failed to preload location", error))
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
.catch(() => {
if (activeUpdates === updates) activeUpdates = undefined
}),
)
refresh(() =>
api()
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { info: location })
}),
)
refresh(() => result.location.vcs.sync())
refresh(() => result.project.sync())
return
}
case "project.updated":
@@ -587,7 +603,7 @@ export function createData(config: CreateDataInput) {
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
// Fetching pending inputs and projected messages separately lets promotion move an input between snapshots,
// causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until
@@ -628,25 +644,28 @@ export function createData(config: CreateDataInput) {
model: event.data.model,
time: { created: event.created },
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
})
.catch((error) => console.error("Failed to load projected model switch message", error))
refresh(() =>
api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
}),
)
return
case "session.renamed": {
// Preserve the live title when it races the session's initial read.
const family = sync.pending(`session.family:${event.data.sessionID}`)
? result.session.sync(event.data.sessionID, { children: true })
: Promise.resolve()
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
refresh(() => {
const family = sync.pending(`session.family:${event.data.sessionID}`)
? result.session.sync(event.data.sessionID, { children: true })
: Promise.resolve()
return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
})
return
}
@@ -680,7 +699,7 @@ export function createData(config: CreateDataInput) {
if (!directory) {
if (info.location.workspaceID) continue
result.session.invalidate(sessionID)
void result.session.sync(sessionID)
refresh(() => result.session.sync(sessionID))
continue
}
const adopted = Worktree.adopt(
@@ -791,7 +810,7 @@ export function createData(config: CreateDataInput) {
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
void result.session.message.sync(event.data.sessionID)
refresh(() => result.session.message.sync(event.data.sessionID))
return
}
case "session.step.started":
@@ -992,12 +1011,12 @@ export function createData(config: CreateDataInput) {
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
return
case "session.viewed":
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
return
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -1105,7 +1124,7 @@ export function createData(config: CreateDataInput) {
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
refresh(() => result.location.integration.sync(location))
return
}
setStore("location", key, (data) => ({
@@ -1123,7 +1142,7 @@ export function createData(config: CreateDataInput) {
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
})
return
}
@@ -1134,19 +1153,19 @@ export function createData(config: CreateDataInput) {
case "catalog.updated":
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
break
case "agent.updated":
result.location.agent.invalidate(location)
void result.location.agent.sync(location)
refresh(() => result.location.agent.sync(location))
break
case "command.updated":
result.location.command.invalidate(location)
void result.location.command.sync(location)
refresh(() => result.location.command.sync(location))
break
case "skill.updated":
result.location.skill.invalidate(location)
void result.location.skill.sync(location)
refresh(() => result.location.skill.sync(location))
break
case "vcs.branch.updated":
setStore("location", locationKey(location), (data) => ({
@@ -1181,31 +1200,33 @@ export function createData(config: CreateDataInput) {
break
case "reference.updated":
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
refresh(() => result.location.reference.sync(location))
break
case "integration.updated":
result.location.integration.invalidate(location)
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
])
refresh(() =>
Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
]),
)
break
case "config.updated":
case "websearch.updated":
void result.location.websearch.refresh(location)
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.
case "mcp.status.changed":
result.location.mcp.server.invalidate(location)
void result.location.mcp.server.sync(location)
refresh(() => result.location.mcp.server.sync(location))
break
case "mcp.resources.changed":
result.location.mcp.resource.invalidate(location)
void result.location.mcp.resource.sync(location)
refresh(() => result.location.mcp.resource.sync(location))
break
}
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()
const errors: unknown[] = []
const state = { offline: true, requests: 0 }
const session: SessionInfo = {
id: "ses_refresh_failure",
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0, idle: 2 },
location: { directory: "/project" },
}
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
state.requests++
if (state.offline) throw new TypeError("Failed to fetch")
return Response.json({ data: { ...session, title: "Recovered" } })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
onError(error) {
errors.push(error)
reported.resolve(error)
},
}),
dispose,
}))
const event: OpenCodeEvent = {
id: "evt_refresh_failure",
created: 2,
type: "session.viewed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, idle: 2 },
}
try {
setup.data.session.remember(session)
setup.data.session.invalidate(session.id)
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
expect(errors).toEqual([])
listeners.forEach((listener) => listener({ name: event.type, details: event }))
expect(String(await reported.promise)).toContain("Transport")
expect(errors).toHaveLength(1)
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
state.offline = false
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await setup.data.session.sync(session.id)
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
expect(state.requests).toBe(3)
} finally {
setup.dispose()
}
})
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const pending = Promise.withResolvers<Response>()
const errors: unknown[] = []
const state = { requests: 0 }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: () => {
state.requests++
return pending.promise
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") },
onError: (error) => errors.push(error),
}),
dispose,
}))
const event: OpenCodeEvent = {
id: "evt_refresh_owner",
type: "command.updated",
location: { directory: "/project" },
data: {},
}
listeners.forEach((listener) => listener({ name: event.type, details: event }))
if (mode === "reconnecting") {
expect(state.requests).toBe(0)
setup.dispose()
return
}
const joined = setup.data.location.command.sync()
setup.dispose()
pending.reject(new TypeError("Failed to fetch"))
await expect(joined).rejects.toThrow("Transport")
expect(state.requests).toBe(1)
expect(errors).toEqual([])
})
+1
View File
@@ -122,6 +122,7 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+1 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
## Search
+6 -2
View File
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
write: Money.USDPerMillionTokens.zero,
},
},
+141
View File
@@ -0,0 +1,141 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}
+2
View File
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+2
View File
@@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { ModalPlugin } from "./provider/modal.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
@@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
ModalPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01"
const apiVersion = "2026-08-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -250,15 +250,26 @@ export const GithubCopilotPlugin = define({
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
// Runs for every route, unlike http.request, which the AI SDK route bypasses.
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.agent, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
@@ -370,11 +381,23 @@ function applyHeaders(
headers.set("User-Agent", App.useragent(app))
headers.set("Openai-Intent", "conversation-edits")
headers.set("X-GitHub-Api-Version", apiVersion)
headers.set("x-initiator", metadata.agent ? "agent" : "user")
// The step may already have declared itself agent-initiated (subagent, title, compaction);
// the body can only ever escalate to "agent", never back to "user".
if (metadata.agent) headers.set("x-initiator", "agent")
else if (!headers.has("x-initiator")) headers.set("x-initiator", "user")
if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
}
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
// nested sessions are subagents, and title/compaction are the two utility overrides.
export function interactionType(agent: Agent.ID, child: boolean) {
if (agent === Agent.ID.make("title")) return "conversation-background"
if (agent === Agent.ID.make("compaction")) return "conversation-compaction"
if (child) return "conversation-subagent"
return "conversation-agent"
}
type RequestMetadata = ReturnType<typeof requestMetadata>
function requestMetadata(url: string, body: unknown) {
@@ -0,0 +1,67 @@
import { Effect, Semaphore, Stream } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { ModalModels } from "../../modal/models.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = Provider.ID.make("modal")
export const ModalPlugin = define({
id: "opencode.provider.modal",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<Model.ID, Model.Info>
} = {}
const load = Effect.fn("ModalPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("modal")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
const provider = yield* catalog.provider.get(providerID)
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
if (!apiKey || !baseURL) {
loaded.baseURL = undefined
loaded.models = undefined
return
}
loaded.baseURL = baseURL
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
loaded.models = yield* Effect.tryPromise({
try: () => ModalModels.get(baseURL, apiKey, existing),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
if (!loaded.models) return
for (const id of item.models.keys()) {
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
+16
View File
@@ -1,8 +1,24 @@
import { dlopen } from "bun:ffi"
import { spawn } from "bun-pty"
import type { Opts, Proc } from "./pty.js"
export type { Disp, Exit, Opts, Proc } from "./pty.js"
if (process.platform === "win32") {
const library = dlopen("kernel32.dll", {
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
GetLastError: { args: [], returns: "u32" },
})
try {
// Detached servers start with Ctrl+C ignored, and ConPTY shells inherit it.
// Clear that attribute once before spawning shells; keep registered handlers.
if (library.symbols.SetConsoleCtrlHandler(null, 0) === 0)
throw new Error(`Failed to enable PTY Ctrl+C handling: Windows error ${library.symbols.GetLastError()}`)
} finally {
library.close()
}
}
function spawnPty(file: string, args: string[], opts: Opts): Proc {
const pty = spawn(file, args, opts)
return {
+109 -61
View File
@@ -1,6 +1,16 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
import {
AIError,
InvalidProviderOutputError,
UnknownProviderError,
isContextOverflowFailure,
LLMClient,
LLMEvent,
LLMRequest,
Message,
type ContentPart,
} from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -12,6 +22,7 @@ import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionRunnerRetry } from "./runner/retry.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
@@ -40,7 +51,7 @@ const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may om
## Work State
### Completed
- [finished work, verified facts, or changes made; otherwise "(none)"]
- [finished work or changes made; otherwise "(none)"]
### Active
- [current work, partial changes, or investigation state; otherwise "(none)"]
@@ -405,68 +416,105 @@ export const layer = Layer.effect(
],
},
})
// Ignored tool calls never enter the follow-up history or need fabricated results.
for (let attempt = 0; attempt < 2; attempt++) {
chunks.length = 0
providerState = undefined
yield* llm
.stream(
attempt === 0
? prepared.request
: LLMRequest.update(prepared.request, {
messages: [
...prepared.request.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
],
}),
prepared.options,
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: context.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[
context.model.model.route.providerMetadataKey ?? context.model.model.provider
]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
const retry = yield* SessionRunnerRetry.policy(context.session.id)
// Both requests share the retry allowance; rejected output never enters the reminder request.
for (const request of [
prepared.request,
LLMRequest.update(prepared.request, {
messages: [
...prepared.request.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
],
}),
]) {
yield* Stream.suspend(() => {
chunks.length = 0
providerState = undefined
failure = undefined
return llm.stream(request, prepared.options)
}).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: context.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
providerState =
event.providerMetadata?.[context.model.model.route.providerMetadataKey ?? context.model.model.provider]
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
if (LLMEvent.is.finish(event)) {
if (event.reason.normalized === "length")
failure = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
if (event.reason.normalized === "content-filter")
failure = {
type: "provider.content-filter",
message: "Compaction summary was blocked by the provider",
}
if (event.reason.normalized === "unknown")
return Effect.fail(
new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
classification: "incomplete-stream",
}),
}),
)
if (event.reason.normalized === "error")
return Effect.fail(
new AIError({ reason: new UnknownProviderError({ message: "Compaction generation failed" }) }),
)
}
return Effect.void
}),
Effect.retry({
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
})
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
)
),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
yield* recordUsage
+33
View File
@@ -1,6 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
@@ -15,6 +16,7 @@ import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionMessageTable } from "../sql.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
@@ -59,6 +61,7 @@ const layer = Layer.effect(
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.awaitActivation
yield* settleStaleCompactions(sessionID)
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -276,6 +279,36 @@ const layer = Layer.effect(
}
})
const settleStaleCompactions = Effect.fn("SessionRunner.settleStaleCompactions")(function* (
sessionID: SessionSchema.ID,
) {
// A process death skips compaction finalizers. Include orphans behind a
// completed checkpoint, and settle newest first to match event projection.
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
for (const row of rows) {
const message = yield* SessionHistory.decodeMessageRow(row)
if (message.type !== "compaction") continue
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: message.reason,
inputID: message.id,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
})
}
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
+7 -2
View File
@@ -78,11 +78,11 @@ const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recur
}),
)
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
export const policy = (sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const step = yield* Schedule.toStep(schedule)
let attempt = 1
const decide = (input: Input) =>
return (input: Input) =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
@@ -104,6 +104,11 @@ export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
return { retry: true as const, attempt, delay: normalized }
})
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
const wait = (input: {
readonly decision: Decision
readonly assistantMessageID: SessionMessage.ID
+10 -1
View File
@@ -1,6 +1,6 @@
export * as OpenCodeTools from "./opencode.js"
import { ToolFailure } from "@opencode-ai/ai"
import { SystemPart, ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
@@ -18,6 +18,15 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.system.push(
SystemPart.make(
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
),
)
}),
)
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
+4 -4
View File
@@ -122,8 +122,8 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
expect(instructions).not.toContain("surrounding top-level agent tools")
expect(instructions).toContain("This catalog is the complete set of tools callable inside `execute`.")
expect(instructions).toContain("It does not affect tools exposed directly outside Code Mode.")
})
test("adds search guidance when the catalog exceeds the budget", () => {
@@ -133,9 +133,9 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("It does not affect tools exposed directly outside Code Mode.")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")

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