Compare commits

...
Author SHA1 Message Date
rekram1-node da532a147a fix(cli): resolve plugins in Node SEA builds 2026-09-07 03:23:05 +00:00
Luke Parker 44513bd0b0 perf(app): serialize persisted stores on a schedule instead of per setter call (#47705) 2026-09-07 13:01:52 +10:00
Luke Parker dc46ecfc55 perf(app): cache storage namespaces and batch writes in the renderer (#47704) 2026-09-07 12:33:09 +10:00
usrnk1andLukeParkerDev f16acb1c95 feat(desktop): improve worktree ui (#47310)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-07 01:26:11 +00:00
opencode-agent[bot] d0d3504780 chore: update nix node_modules hashes 2026-09-07 01:05:55 +00:00
Luke Parker 5e3100a46a fix(desktop): persist renderer state in SQLite instead of electron-store (#47695) 2026-09-07 10:51:46 +10:00
Luke Parker 69b30acfd4 chore: stop trusting tree-sitter install scripts (#47696) 2026-09-07 00:09:44 +00:00
Luke Parker ecb3c88601 fix(app): give worktree creation a setup-length request deadline (#47694) 2026-09-06 23:44:45 +00:00
opencode-agent[bot]andHona a5ebf5bf2f fix(www): regenerate OpenAPI documents (#47693)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-06 23:26:56 +00:00
Luke Parker f334377c5b fix(util): bound opencode.log by trimming its head in place (#47676) 2026-09-07 09:16:09 +10:00
Luke Parker 873d437c12 fix(desktop): emit preload as .cjs so it loads under --no-sandbox (#47691) 2026-09-07 09:14:38 +10:00
Aiden Cline 13286e883e fix(ai): raise typed errors for failed finishes (#47688) 2026-09-06 18:09:49 -05:00
Luke Parker 0d70595338 fix(desktop): export tail of oversized logs in debug bundle (#47672) 2026-09-07 08:47:30 +10:00
Aiden Cline a02415a3eb fix(ai): restrict Bedrock cache checkpoints to Claude (#47660) 2026-09-06 17:00:12 -05:00
Dax beca5e8926 feat(project): allow updating canonical directory (#47642) 2026-09-06 17:52:48 -04:00
Shoubhit Dash e3c89654d1 feat(core): keep web search providers sticky per session (#47334) 2026-09-07 03:09:47 +05:30
Dax 33f48f36c9 docs(www): add Console documentation (#47638) 2026-09-06 12:06:07 -04:00
Dax 58f949d2d0 fix(core): log MCP OAuth and credential lifecycle (#47636) 2026-09-06 11:56:42 -04:00
Kit Langton 2a895b9e03 fix(core): interrupt sessions before inactivity eviction (#47629) 2026-09-06 11:11:42 -04:00
Dax Raad fe506f201d docs: explain supporting v1 and v2 plugins 2026-09-06 11:08:26 -04:00
Luke Parker b2cecc6350 fix(desktop): add sidecar credentials from the main process (#47588) 2026-09-06 07:18:12 +00:00
Luke Parker 63a1074c6c fix(app): keep slow git reads from filling the request queue (#47564) 2026-09-06 17:02:03 +10:00
Luke Parker 31ee07e3ae fix(app): pace directory re-sync after reconnect (#47565) 2026-09-06 17:01:24 +10:00
Aarav Sareen 370b9965d3 feat(app): cmd+F to search session with highlighting 2026-09-06 16:47:20 +10:00
Dax Raad 8ea99ef9ad fix(tui): dismiss update notification only on skip 2026-09-06 02:43:21 -04:00
Aarav Sareen e64b2bc137 feat(app): add horizontal file sidebar scrolling + right click menu 2026-09-06 16:35:14 +10:00
opencode-agent[bot]andrekram1-node cf1923c238 fix(core): disable default GPT and Claude search filtering (#47586)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 01:22:48 -05:00
Luke Parker bfcb388dd7 feat(app): pulse the status dot while the event stream reconnects (#47574) 2026-09-06 15:13:38 +10:00
usrnk1 ec46439ef5 feat(desktop): simplify move to background action 2026-09-06 14:45:16 +10:00
Luke Parker 1be3b32a47 fix(client): detect stalled event streams and resync on foreground (#47571) 2026-09-06 04:43:50 +00:00
Aiden Cline 2823b886d7 feat(core): add independent GPT and Claude tool optimization (#47559) 2026-09-05 23:43:23 -05:00
Luke Parker 99651b2d50 fix(app): refresh queued inputs when the connection returns (#47573) 2026-09-06 04:42:27 +00:00
usrnk1 33ef66746b feat(desktop): add third-party web search consent 2026-09-06 14:38:55 +10:00
Luke Parker cf212a4235 fix(app): time out requests the server never answers (#47572) 2026-09-06 14:28:43 +10:00
David Hill 768e3d45a2 fix(ui): lighten dark contrast icon buttons 2026-09-06 14:01:47 +10:00
Luke Parker 0674ddf043 fix(client): coalesce catalog refetches from event bursts (#47561) 2026-09-06 03:48:49 +00:00
David Hill 41b66bc560 fix(app): scroll overflowing composer controls 2026-09-06 13:47:42 +10:00
opencode-agent[bot] 49a5baa059 chore: update nix node_modules hashes 2026-09-06 03:39:31 +00:00
Luke Parker 1cf576ab55 fix(desktop): keep server CORS headers so preflights cache (#47560) 2026-09-06 03:26:12 +00:00
David Hill f7d0f69e6c feat(app): add subtle new-session logo shimmer 2026-09-06 13:22:15 +10:00
Aiden Cline a66e6cd55e feat(core): discover Bedrock credentials in the provider plugin (#47548) 2026-09-05 22:20:48 -05:00
David Hill 93b3725b14 fix(app): move project icon setting to experimental 2026-09-06 13:07:37 +10:00
David Hill 371e344ba6 fix(app): widen new-session prompt beyond logo 2026-09-06 13:07:33 +10:00
David Hill 6559dcb01b fix(tui): list OpenCode Go before Zen 2026-09-06 13:07:29 +10:00
David Hill 35dbdfe287 fix(app): simplify vertical tabs footer 2026-09-06 13:07:24 +10:00
opencode-agent[bot] cd504dc66a chore: update nix node_modules hashes 2026-09-06 00:05:05 +00:00
Aiden Cline c4f2926ffb feat(ai): resolve Bedrock credentials through the AWS default chain (#47436) 2026-09-05 18:49:05 -05:00
opencode-agent[bot]andrekram1-node aa48433fa0 fix(core): send session ID with Copilot requests (#47213)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 18:42:46 -05:00
Kit Langton 8da7526616 fix(core): reload local plugin helpers without restarting
Reuse the shared dependency-aware plugin loader for Core hot reloads. Scope watcher setup callbacks and subscriptions, recover missing external helpers, and preserve explicit local entrypoint invalidation without reloading package dependencies. Add regression coverage and portable RPC fixtures.
2026-09-05 17:00:41 -04:00
opencode-agent[bot]andrekram1-node 7a4ad68af6 fix(mcp): retry initialization without codemode on HTTP 400 (#47507)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 15:49:25 +00:00
opencode-agent[bot]andnexxeln 2960c61f9c fix(tui): preserve parent scroll position across subagents (#47464)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 15:19:16 +05:30
opencode-agent[bot]andnexxeln 7ad705225e fix(tui): avoid disconnection error on terminal exit (#47272)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 14:35:40 +05:30
opencode-agent[bot]andHona 5045b3ce18 fix(ui): keep file accordion borders visible when scaled (#47461)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 08:47:54 +00:00
Aiden Cline 4306c07b34 feat(core): update GPT prompts and remove legacy Anthropic prompt (#47447) 2026-09-05 01:39:18 -05:00
opencode-agent[bot] 7de1e86b5d chore: update nix node_modules hashes 2026-09-05 06:38:20 +00:00
opencode-agent[bot]andrekram1-node a6fd1f8b22 chore: bump Bun to 1.4.2 (#47446)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 01:21:42 -05:00
Dax Raad 52685d4517 fix(tui): simplify home update notices 2026-09-05 02:17:09 -04:00
Luke Parker 30d1049942 fix(app): load worktree inventory on demand and cap concurrent server requests (#47441) 2026-09-05 05:25:43 +00:00
Stefan Avramandslickstef11 21adcb4969 fix(console): route enterprise forms to Chatwoot (#47437)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 00:05:40 -04:00
Kit Langton c72b535dee fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
2026-09-04 23:41:02 -04:00
opencode-agent[bot]andjlongster 8d1a9799f4 fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-04 22:46:28 -04:00
David Hill 86ba09c6e0 fix(app): increase vertical tabs minimum width (#47424) 2026-09-05 12:10:12 +10:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
Aiden Cline 541937d124 fix(core): clarify how to call search in Code Mode guidance (#47360) 2026-09-04 18:50:47 -05:00
307 changed files with 12561 additions and 3138 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.1
bun-version: 1.4.2
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.1
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+11 -20
View File
@@ -33,6 +33,7 @@
"name": "@opencode-ai/ai",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -338,7 +339,6 @@
"version": "1.18.4",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cohere": "3.0.27",
@@ -350,7 +350,6 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
@@ -433,6 +432,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -1037,12 +1037,9 @@
},
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
"esbuild",
"protobufjs",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -1067,7 +1064,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1189,7 +1186,7 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -3487,7 +3484,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -5867,6 +5864,8 @@
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5875,8 +5874,6 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -5923,9 +5920,9 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6385,8 +6382,6 @@
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -7247,10 +7242,6 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2
View File
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
}
}
+3 -7
View File
@@ -5,9 +5,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.1",
"packageManager": "bun@1.4.2",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
@@ -146,10 +146,6 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
@@ -159,7 +155,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:"
},
+1
View File
@@ -28,6 +28,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"@opencode-ai/schema": "workspace:*",
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -431,7 +428,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const breakpoints = BedrockCache.breakpoints(request.model.id)
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
+8
View File
@@ -639,6 +639,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
: state.usage,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
return Effect.fail(
ProviderShared.eventError(
state.route,
`Gemini stopped with ${candidate.finishReason}`,
ProviderShared.encodeJson(event),
),
)
if (!candidate?.content)
return Effect.succeed([
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
+12
View File
@@ -9,6 +9,8 @@ import {
AIError,
InvalidProviderOutputError,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReasonDetails,
type LLMRequest,
@@ -700,6 +702,16 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
if (finishReason.normalized === "error") {
const details = {
message: `Mistral Chat stopped with ${finishReason.raw}`,
body: ProviderShared.encodeJson(event),
}
return yield* new AIError({
reason:
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
})
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth.js"
import { AIError, AuthenticationError } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
* which provider facades configure as route auth instead of SigV4.
*/
export interface Credentials {
readonly region: string
@@ -17,6 +16,44 @@ export interface Credentials {
readonly sessionToken?: string
}
/** Static credentials or an effect resolved before every request. */
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
export interface DefaultChainOptions {
readonly region: string
/** Shared config profile passed to the AWS default chain. */
readonly profile?: string
}
/**
* Resolve credentials through the AWS default provider chain: environment
* variables, shared config and SSO caches, web identity tokens, process
* credentials, and container or instance metadata. A fresh chain runs on every
* request so credentials rotated on disk without an expiration (for example
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
* the SDK's own memoization would otherwise pin them for the process lifetime.
*/
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
secretAccessKey: identity.secretAccessKey,
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
}
},
catch: (error) =>
new AIError({
reason: new AuthenticationError({
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
cause: error,
}),
}),
})
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -48,16 +85,17 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | undefined,
source: CredentialSource | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
if (!source) {
return yield* ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const credentials = Effect.isEffect(source) ? yield* source : source
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
@@ -74,4 +112,35 @@ export const sigV4 = (
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
export const auth = sigV4(undefined)
export const resolveRegion = (input: {
readonly region?: string
readonly credentials?: { readonly region: string }
}) =>
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
export interface ResolveAuthInput {
readonly apiKey?: string
readonly credentials?: Credentials
readonly profile?: string
}
export interface ResolveAuthOptions {
readonly service?: string
readonly name?: string
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
readonly mode?: "bearer" | "sigv4"
}
/**
* Bearer wins over SigV4 and explicit static credentials win over the default
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
* The region is applied to the SigV4 scope so it always matches the endpoint host.
*/
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
if (apiKey !== undefined) return Auth.bearer(apiKey)
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
return sigV4(defaultChain({ region, profile: input.profile }), options)
}
export * as BedrockAuth from "./bedrock-auth.js"
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema/index.js"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
import { newBreakpoints, ttlBucket } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock
@@ -13,24 +13,46 @@ export const CachePointBlock = Schema.Struct({
})
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
// Callers pass a shared counter through every `block()` call site so the
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
// These legacy Claude releases support explicit caching, but only for five minutes.
const CLAUDE_5M = [
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
]
// Callers share the four-breakpoint budget across system, messages, and tools.
export const BEDROCK_BREAKPOINT_CAP = 4
export type { Breakpoints } from "./cache.js"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
export const breakpoints = (modelID: string) => {
// Substring matching also handles regional prefixes and model-bearing ARNs.
const short = CLAUDE_5M.some((id) => modelID.includes(id))
return {
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
// Other model families use implicit caching where available.
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
ttl1h: !short,
}
}
export type Breakpoints = ReturnType<typeof breakpoints>
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
if (!breakpoints.supported) return undefined
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
}
export * as BedrockCache from "./bedrock-cache.js"
@@ -1,4 +1,3 @@
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: Credentials
/** Shared config profile for the default credential chain. */
readonly profile?: string
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
const region = BedrockAuth.resolveRegion(input)
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
mode: input.auth,
}),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
const {
apiKey: _,
auth: _auth,
baseURL: _baseURL,
credentials: _credentials,
profile: _profile,
region: _region,
...rest
} = input
return rest
}
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
const fromSettings = (settings: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
) => fromSettings(settings).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
) => fromSettings(settings).responses(modelID)
export const model = responsesModel
+20 -13
View File
@@ -1,17 +1,23 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly headers?: Record<string, string>
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
/** Shared config profile for the default credential chain. */
readonly profile?: string
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
})
}
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
region: settings.region,
}).model(modelID)
}
+30
View File
@@ -0,0 +1,30 @@
import { Effect } from "effect"
/**
* Run an effect with `process.env` overrides, restoring the previous values
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
* credential providers, where Effect `ConfigProvider` layers do not apply.
* `undefined` removes a variable for the duration of the effect.
*/
export const withProcessEnv =
(env: Record<string, string | undefined>) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
apply(env)
return previous
}),
() => effect,
(previous) => Effect.sync(() => apply(previous)),
)
const apply = (env: Record<string, string | undefined>) => {
for (const [name, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[name]
continue
}
process.env[name] = value
}
}
@@ -0,0 +1,119 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
describe("Bedrock Converse cache policy", () => {
for (const id of [
"deepseek.r1-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
"mistral.mistral-large-2402-v1:0",
"qwen.qwen3-coder-480b-a35b-v1:0",
"openai.gpt-oss-120b-1:0",
"cohere.command-r-v1:0",
"anthropic.claude-instant-v1",
"anthropic.claude-v1",
"anthropic.claude-v2",
"anthropic.claude-v2:1",
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
"amazon.nova-lite-v1:0",
"global.amazon.nova-2-lite-v1:0",
"custom-model",
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
]) {
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
Effect.gen(function* () {
// Exercise both automatic placement and manual hints at every lowering site.
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
cache: policy,
system: [{ type: "text", text: "System prefix", cache }],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
messages: [
Message.user([{ type: "text", text: "Question", cache }]),
Message.system([{ type: "text", text: "Update", cache }]),
Message.assistant([
{ type: "text", text: "Answer", cache },
{ type: "reasoning", text: "Unsigned reasoning", cache },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
],
}),
)
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
expect(prepared.body).toMatchObject({
modelId: id,
system: [{ text: "System prefix" }],
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
messages: [
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
{
role: "assistant",
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
})
}),
)
}
}
for (const [id, ttl] of [
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
["global.anthropic.claude-sonnet-99", "1h"],
["anthropic.claude-new-family-99", "1h"],
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
] as const) {
for (const ttlSeconds of [undefined, 3600]) {
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
system: [
{ type: "text", text: "Agent" },
{ type: "text", text: "Project" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
prompt: "Question",
cache:
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
}),
)
const marker = {
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
}
expect(prepared.body).toMatchObject({
modelId: id,
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
})
if (ttlSeconds === undefined || ttl === undefined)
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
}),
)
}
}
})
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
GenerationOptions,
type LanguageModel,
LLM,
LLMEvent,
LLMRequest,
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import {
eventSummary,
expectWeatherToolLoop,
@@ -95,10 +98,45 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
)
// Clears every input the AWS default chain reads so tests do not pick up the
// developer's profiles, SSO cache, or instance metadata.
const noAmbientAWS = {
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_SESSION_TOKEN: undefined,
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_REGION: undefined,
AWS_DEFAULT_REGION: undefined,
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
AWS_EC2_METADATA_DISABLED: "true",
}
const captureHeaders = (target: LanguageModel) =>
Effect.gen(function* () {
const seen: Array<Headers> = []
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers)
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
headers: { "content-type": "application/vnd.amazon.eventstream" },
})
}),
),
),
)
return seen[0]!
})
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
const baseRequest = LLM.request({
id: "req_1",
@@ -117,7 +155,7 @@ describe("Bedrock Converse route", () => {
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
inferenceConfig: { maxTokens: 64, temperature: 0 },
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects requests with no auth path", () =>
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
profile: "opencode-test-missing-profile",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip,
)
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
}),
expect(error.reason._tag).toBe("Authentication")
expect(error.message).toContain("AWS default credential chain failed")
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
Effect.gen(function* () {
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
region: "eu-west-1",
credentials: {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
const headers = yield* captureHeaders(signed)
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
}),
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBeNull()
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(chained)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: "chain-session-token",
}),
),
)
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const first = yield* captureHeaders(chained)
const second = yield* captureHeaders(chained).pipe(
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
)
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
Effect.gen(function* () {
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(bearer)
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
Effect.gen(function* () {
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
auth: "sigv4",
baseURL: "https://bedrock-runtime.test",
})
for (const target of [configured, fromSettings]) {
const headers = yield* captureHeaders(target)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_REGION: "ap-southeast-2",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
Effect.gen(function* () {
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
const fromDefault = yield* Effect.sync(() =>
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
).pipe(withProcessEnv({ AWS_REGION: undefined }))
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
)
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
)
it.effect("signs with the Mantle service using default-chain credentials", () =>
Effect.gen(function* () {
const seen: Array<string | undefined> = []
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers.get("authorization") ?? undefined)
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
}).pipe(
withProcessEnv({
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: undefined,
}),
),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
+21 -6
View File
@@ -1643,19 +1643,14 @@ describe("Gemini route", () => {
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
@@ -1666,6 +1661,26 @@ describe("Gemini route", () => {
)
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const raw of [
"MALFORMED_FUNCTION_CALL",
"UNEXPECTED_TOOL_CALL",
"NO_IMAGE",
"TOO_MANY_TOOL_CALLS",
"MISSING_THOUGHT_SIGNATURE",
"MALFORMED_RESPONSE",
]) {
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
message: `Gemini stopped with ${raw}`,
})
}
}),
)
+16 -1
View File
@@ -600,7 +600,6 @@ describe("Mistral Chat", () => {
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
@@ -609,6 +608,22 @@ describe("Mistral Chat", () => {
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const [raw, tag] of [
["error", "UnknownProvider"],
["network_error", "ProviderInternal"],
] as const) {
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
message: `Mistral Chat stopped with ${raw}`,
})
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
+130 -7
View File
@@ -1,16 +1,139 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
for (const direction of ["ltr", "rtl"]) {
for (const alternate of ["none", "queue", "steer"]) {
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--toolbar-overflow", {
args: { alternate },
globals: { direction },
})
const controls = component.locator('[data-slot="composer-controls"]')
const actions = component.locator('[data-slot="composer-actions"]')
const submit = component.locator('[data-action="composer-submit"]')
const add = component.locator('[data-action="composer-attach"]')
const agent = controls.getByRole("button", { name: "Choose agent" })
const variant = controls.getByRole("button", { name: "Choose model variant" })
await expect(controls).toHaveCSS("overflow-x", "auto")
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
await expect(controls).toHaveCSS("direction", direction)
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeGreaterThan(0)
const fixed = await submit.boundingBox()
const fixedAdd = await add.boundingBox()
const viewport = await controls.boundingBox()
const action = await actions.boundingBox()
expect(fixed).not.toBeNull()
expect(viewport).not.toBeNull()
expect(action).not.toBeNull()
expect(fixedAdd).not.toBeNull()
if (!fixed || !viewport || !action || !fixedAdd) return
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
1,
)
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
direction === "ltr" ? action.x - 12 : viewport.x - 12,
1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
await controls.evaluate((element) => {
element.scrollLeft = 0
})
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
await expect(controls).toHaveCSS(
"mask-image",
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
)
const first = await agent.boundingBox()
if (!first) throw new Error("Missing agent control")
expect(
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
).toBeCloseTo(0, 0)
await controls.evaluate((element) => {
element.scrollLeft =
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
const scrolled = (await controls.boundingBox())!
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
await controls.hover()
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
expect(await submit.boundingBox()).toEqual(fixed)
expect(await add.boundingBox()).toEqual(fixedAdd)
// The fade disappears at the endpoint instead of reserving padding.
await variant.focus()
await controls.evaluate((element) => {
element.scrollLeft =
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
const last = await variant.boundingBox()
if (!last) throw new Error("Missing variant control")
expect(
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
).toBeLessThan(1)
expect(
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
await expect(add).toBeInViewport()
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
expect(await add.boundingBox()).toEqual(fixedAdd)
}
if (alternate !== "none") {
const width = (await controls.boundingBox())!.width
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
}
})
}
}
story("does not mask or pad controls when they fit", async ({ mount }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const controls = component.locator('[data-slot="composer-controls"]')
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
await expect(controls).toHaveCSS("mask-image", "none")
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
["light", "rgb(255, 255, 255)"],
["dark", "rgb(36, 36, 36)"],
] as const) {
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
})
}
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
@@ -0,0 +1,86 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
for (const direction of ["ltr", "rtl"]) {
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "no-preference" })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
const logo = component.locator('[data-component="new-session-wordmark"]')
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
const shimmer = logo.locator(".wordmark-shimmer")
const base = logo.locator("svg").first()
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
await expect(logo.locator("g[mask]")).toHaveCount(0)
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => {
animation.pause()
animation.currentTime = 0
})
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(reveal).toHaveCSS("animation-name", "none")
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
})
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(shimmer).toHaveCSS("opacity", "0")
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
const brightness = await page.evaluate(
async (screenshots) => {
const pixels = await Promise.all(
screenshots.map(async (screenshot) => {
const image = new Image()
image.src = `data:image/png;base64,${screenshot}`
await image.decode()
const canvas = new OffscreenCanvas(image.width, image.height)
const context = canvas.getContext("2d")
if (!context) throw new Error("Cannot read screenshot pixels")
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, image.width, image.height).data
}),
)
return {
min: pixels[0].reduce(
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
0,
),
max: pixels[0].reduce(
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
0,
),
}
},
[swept.toString("base64"), settled.toString("base64")],
)
expect(brightness.min).toBeGreaterThanOrEqual(0)
expect(brightness.max).toBeGreaterThanOrEqual(5)
})
}
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 360, height: 640 })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
const logo = component.locator('[data-component="new-session-wordmark"]')
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
})
}
@@ -0,0 +1,79 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}
+1
View File
@@ -43,6 +43,7 @@ The suite contains:
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- large-session search scan, first-result reveal, and highlight stabilization
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
@@ -0,0 +1,69 @@
import { benchmark, expect } from "../benchmark"
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
import {
collectTimelineSearchMetrics,
installTimelineSearchProbe,
waitForStableTimelineSearch,
} from "./session-timeline-search-probe"
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
benchmark.setTimeout(180_000)
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
const query = "Historical prompt"
const targetPartID = "msg_0000_0000_a_user:text:0"
const expectedCounter = `1/${historyTurns}`
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch: 1,
})
fixture.transport.enqueue(buildInitialStreamEvent(1))
await expect(fixture.text).toContainText("Implementation plan")
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
const search = page.locator('[data-component="timeline-search-bar"]')
const field = search.getByRole("searchbox", { name: "Find..." })
const count = search.locator('[data-slot="timeline-search-count"]')
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
await expect(field).toBeVisible()
await expect(field).toBeFocused()
await installTimelineSearchProbe(page, { targetPartID })
await field.fill(query)
await expect(count).toHaveText(expectedCounter)
await expect(target).toBeVisible({ timeout: completionTimeout })
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
expect(metrics.summary.handlerDurationMs).toBeDefined()
expect(metrics.summary.firstCountObservedMs).toBeDefined()
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
expect(metrics.summary.stableResultObservedMs).toBeDefined()
expect(metrics.summary.activeHighlightRanges).toBe(1)
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
// Check navigation and the V2 assistant content IDs outside the measured interval.
await field.press("Enter")
await expect(count).toHaveText(`2/${historyTurns}`)
await field.press("Shift+Enter")
await expect(count).toHaveText(expectedCounter)
await field.fill("Implementation plan")
await expect(count).toHaveText("1/1")
await expect(fixture.text).toBeInViewport()
await expect
.poll(() =>
page.evaluate(() => {
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
.timelinePartId
}),
)
.toBe(textPartID)
await field.press("Escape")
await expect(search).toBeHidden()
})
@@ -0,0 +1,176 @@
import type { Page } from "@playwright/test"
export type TimelineSearchSample = {
observedAtMs: number
counter: string
targetMounted: boolean
targetVisible: boolean
targetTopPx?: number
activeRanges: number
activePartID?: string
activeVisible: boolean
scrollTopPx: number
}
type TimelineSearchProbe = {
samples: TimelineSearchSample[]
handlerDurationMs?: number
initialScrollTopPx: number
stop: () => void
}
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
await page.evaluate(({ targetPartID }) => {
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
const samples: TimelineSearchSample[] = []
const initialScrollTopPx = root.scrollTop
let startedAt: number | undefined
let handlerDurationMs: number | undefined
let frame: number | undefined
let running = true
const visibleInRoot = (rect: DOMRect) => {
const viewport = root.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
}
const sample = () => {
if (!running || startedAt === undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
setTimeout(() => {
if (!running || startedAt === undefined) return
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
const targetRect = target?.getBoundingClientRect()
const highlight = CSS.highlights.get("timeline-search-hit-active")
const ranges = highlight ? [...highlight] : []
const active = ranges.find((range): range is Range => range instanceof Range)
const activeRect = active?.getBoundingClientRect()
const activeElement =
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
samples.push({
observedAtMs: performance.now() - startedAt,
counter:
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
targetMounted: !!target,
targetVisible: !!targetRect && visibleInRoot(targetRect),
targetTopPx: targetRect?.top,
activeRanges: ranges.length,
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
activeVisible: !!activeRect && visibleInRoot(activeRect),
scrollTopPx: root.scrollTop,
})
sample()
}, 0)
})
}
const onInputCapture = (event: Event) => {
if (event.target !== field || startedAt !== undefined) return
startedAt = performance.now()
sample()
}
const onInput = (event: Event) => {
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
handlerDurationMs = performance.now() - startedAt
}
document.addEventListener("input", onInputCapture, { capture: true })
document.addEventListener("input", onInput)
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
samples,
initialScrollTopPx,
get handlerDurationMs() {
return handlerDurationMs
},
stop: () => {
running = false
document.removeEventListener("input", onInputCapture, { capture: true })
document.removeEventListener("input", onInput)
if (frame !== undefined) cancelAnimationFrame(frame)
},
}
}, input)
}
export async function waitForStableTimelineSearch(
page: Page,
input: { counter: string; targetPartID: string; timeout: number },
) {
await page.waitForFunction(
({ counter, targetPartID }) => {
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
if (stable.length !== 3) return false
return stable.every(
(sample, sampleIndex) =>
sample.counter === counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
},
{ counter: input.counter, targetPartID: input.targetPartID },
{ timeout: input.timeout },
)
}
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
const result = await page.evaluate(() => {
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
if (!probe) throw new Error("missing timeline search benchmark probe")
probe.stop()
return {
samples: probe.samples,
handlerDurationMs: probe.handlerDurationMs,
initialScrollTopPx: probe.initialScrollTopPx,
}
})
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
const stable = result.samples.findIndex((_, index) => {
const samples = result.samples.slice(index, index + 3)
if (samples.length !== 3) return false
return samples.every(
(sample, sampleIndex) =>
sample.counter === input.counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === input.targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
const final = result.samples.at(-1)
return {
summary: {
handlerDurationMs: result.handlerDurationMs,
firstCountObservedMs: first((sample) => sample.counter === input.counter),
firstTargetMountedMs: first((sample) => sample.targetMounted),
firstTargetVisibleMs: first((sample) => sample.targetVisible),
firstActiveHighlightObservedMs: first(
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
),
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
initialScrollTopPx: result.initialScrollTopPx,
finalScrollTopPx: final?.scrollTopPx,
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
activeHighlightRanges: final?.activeRanges,
},
samples: result.samples,
}
}
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: current.directory,
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
const longPath = `frontend/${longFilename}`
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
vcsDiff: [
{
file: longPath,
before: "",
after: "export const added = true\n",
additions: 1,
deletions: 0,
status: "added",
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
},
],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
@@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
type: "file" as const,
ignored: false,
},
{
name: longFilename,
path: `frontend\\${longFilename}`,
absolute: `${directory}/${longPath}`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
@@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
},
]
},
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
@@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
const longRow = panel.getByRole("button", { name: longFilename })
await expect(longRow).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
expect(
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
).toBe("clip")
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
await viewport.evaluate((element) => element.clientWidth),
)
await expect
.poll(() =>
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
const widths = rows.map((row) => row.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThanOrEqual(0.5)
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
await expect(status).toHaveText("A")
const statusBox = await status.boundingBox()
if (!statusBox) throw new Error("File status has no bounding box")
const viewportBox = await viewport.boundingBox()
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
await viewport.hover()
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
await expect(horizontalThumb).toHaveCSS("opacity", "1")
await page.mouse.wheel(1_000, 0)
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
await expect
.poll(() =>
status.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.right - element.getBoundingClientRect().right
}),
)
.toBeLessThanOrEqual(24)
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
const thumbBox = await horizontalThumb.boundingBox()
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill(longFilename)
const filteredRow = panel.getByRole("option", { name: longFilename })
await expect(filteredRow).toBeVisible()
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
await expect(filteredStatus).toHaveText("A")
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await viewport.evaluate((element) => {
element.setAttribute("dir", "rtl")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await expect
.poll(() =>
filteredStatus.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return element.getBoundingClientRect().left - viewport.left
}),
)
.toBeLessThanOrEqual(24)
const rtlThumbBox = await horizontalThumb.boundingBox()
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
await viewport.evaluate((element) => {
element.removeAttribute("dir")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await filter.fill("")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test.use({
deviceScaleFactor: 2,
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
launchOptions: { args: ["--force-device-scale-factor=2"] },
})
for (const width of [1400, 390]) {
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
"prt_border_patch",
"patch",
"completed",
{ patchText: "Update file" },
{
metadata: {
files: [
{
file,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
},
],
},
},
),
]),
],
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
const patch = page.locator('[data-component="apply-patch-tool"]')
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger).toHaveCSS("height", "32px")
for (const side of ["top", "right", "bottom", "left"]) {
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
}
const box = await trigger.boundingBox()
await trigger.hover()
expect(await trigger.boundingBox()).toEqual(box)
await page.mouse.move(0, 0)
expect(await trigger.boundingBox()).toEqual(box)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const content = patch.locator('[data-slot="accordion-content"]')
await expect(content).toBeVisible()
for (const side of ["left", "right", "bottom"]) {
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
}
await expect(content).toHaveCSS("border-top-width", "0px")
await trigger.press("Enter")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -396,7 +396,12 @@ async function mockServers(
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/location")
return json(route, {
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
@@ -53,7 +53,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
@@ -267,7 +267,7 @@ for (const state of ["closed", "unopened"] as const) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-worktree")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
@@ -154,8 +154,8 @@ test("worktree deletion sends the project location separately from the target",
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
await settings.getByRole("button", { name: "Delete workspace-1”?", exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete workspace-1”?", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
@@ -225,19 +225,90 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await expect(sidebar).toHaveCSS("width", "140px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
for (const count of [0, 26]) {
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, directory, count }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: count }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_navigation_${index}`,
})),
),
)
},
{ server, directory: sessionA.directory, count },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
for (const width of [260, 180, 140]) {
if (width !== 260) {
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
const bounds = await sidebar.boundingBox()
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
await page.mouse.up()
}
await expect(sidebar).toHaveCSS("width", `${width}px`)
await testInfo.attach(`navigation-${count}-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
for (const name of ["Home", "New session"]) {
const button = sidebar.getByRole("button", { name, exact: true })
const label = button.getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await button.hover()
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await expect
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.getByRole("main").hover()
}
}
})
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -254,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const status = sidebar.getByRole("button", { name: "Status", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await expect(status).toHaveText("Status")
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
@@ -285,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -304,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(settings).toHaveCount(0)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -359,9 +431,9 @@ for (const profile of [
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect(hint).toBeVisible()
await expect
.poll(() =>
hint.evaluate((element) => {
@@ -373,7 +445,7 @@ for (const profile of [
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
@@ -381,10 +453,10 @@ for (const profile of [
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
})
}
@@ -560,7 +632,12 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectToken(
@@ -92,7 +92,7 @@ for (const theme of ["light", "dark"] as const) {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const url = page.url()
const refreshed = page.waitForResponse(
@@ -109,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
view.events.push({
id: "evt_workspace_accent_running",
@@ -127,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await expectBackground(stop, "icon-button-contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
})
test("new workspace send button stays neutral", async ({ page }) => {
const view = await openSession(page, root, [...inventory], true)
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
})
})
}
+37 -5
View File
@@ -55,6 +55,8 @@ function ComposerStory(props: {
label?: string
inspectRequest?: boolean
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -66,11 +68,15 @@ function ComposerStory(props: {
activity: props.label ?? "Ready",
variant: STORY_MODEL.variant,
})
const modelOption = createMemo(() => ({
...selectedModel,
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
}))
const modelSelection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
current: () => modelOption(),
recent: () => [modelOption()],
list: () => [modelOption()],
cycle() {},
set() {},
visible: () => true,
@@ -126,7 +132,7 @@ function ComposerStory(props: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: "build" },
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
{ id: "review", label: "review" },
],
current: () => "build",
@@ -135,7 +141,7 @@ function ComposerStory(props: {
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: "balanced" },
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
@@ -144,6 +150,17 @@ function ComposerStory(props: {
submit: {
stopping: () => !!props.stopping,
working: () => !!props.working,
queue: props.alternate
? {
count: () => 0,
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
cancelEdit() {},
editFirst: () => false,
}
: undefined,
onSubmit: () => {
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
const request = props.inspectRequest
@@ -290,6 +307,21 @@ export const NarrowLayout = {
),
}
export const ToolbarOverflow = {
args: { alternate: "queue" },
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
render: (args: { alternate: "none" | "queue" | "steer" }) => (
<div class="w-[min(420px,calc(100vw-80px))]">
<ComposerStory
longLabels
working
prompt={text("Keep all controls reachable when the toolbar overflows")}
alternate={args.alternate === "none" ? undefined : args.alternate}
/>
</div>
),
}
export const DemoFirstClassSkillIDs = {
name: "Demo: First-class skill IDs",
render: () => (
@@ -2,6 +2,29 @@
content: "\200B";
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
}
[data-slot="composer-controls"][data-overflow-end="true"] {
--mask-end: 16px;
}
[data-slot="composer-controls"]:dir(rtl) {
--mask-direction: to left;
}
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
black var(--mask-start, 0px),
black calc(100% - var(--mask-end, 0px)),
transparent
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
+90 -60
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
setOverflow({
start: offset > 1,
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
observer.observe(controlsContent)
updateOverflow()
onCleanup(() => observer.disconnect())
})
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<div class="flex h-11 items-center px-2">
<div
class="flex min-w-0 flex-1 items-center gap-1"
class="flex shrink-0 items-center"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
<Show when={view.agent} keyed>
{(control) => (
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
</div>
<div
ref={controlsViewport}
data-slot="composer-controls"
data-overflow-start={overflow.start}
data-overflow-end={overflow.end}
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
onScroll={updateOverflow}
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
<Show when={view.agent} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
</Show>
</Show>
</div>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
<div data-slot="composer-actions" class="flex shrink-0 items-center">
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
</div>
</form>
</div>
@@ -808,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
tabIndex={props.mode === "normal" ? undefined : -1}
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
variant="contrast"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
class="size-7 rounded-md p-[6px] disabled:opacity-50"
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
+2
View File
@@ -7,5 +7,7 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+8
View File
@@ -34,6 +34,14 @@ export function createHomeController() {
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const ctx = focusedServerCtx()
const id = selectedProject()?.id
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void ctx.sync.worktrees.load(root)
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project = homeProjectForSession(session, home.project.list())
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
test("labels a worktree session with its project before that project's inventory has loaded", () => {
const records = buildHomeSessionRecords({
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
projectDirectories: () => undefined,
projects: () => [{ ...opened, name: "Project A" }],
})
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
expect(records[0]?.projectName).toBe("Project A")
})
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
const records = buildHomeSessionRecords({
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
projectDirectories: () => undefined,
projects: () => [opened, nested],
})
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
+16 -7
View File
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const directory = pathKey(session.location.directory)
const project = input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
const project = homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
return { session, project, projectName: displayName(project) }
})
}
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
// the session's project ID still identifies its added project.
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
session: SessionInfo,
projects: readonly T[],
) {
const directory = pathKey(session.location.directory)
return (
projects.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projects.find((item) => item.id === session.projectID)
)
}
+9
View File
@@ -165,6 +165,15 @@
}
}
::highlight(timeline-search-hit) {
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
}
::highlight(timeline-search-hit-active) {
background-color: var(--v2-icon-icon-accent);
color: var(--v2-background-bg-deep);
}
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
+2 -2
View File
@@ -1,2 +1,2 @@
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
+61 -17
View File
@@ -1,7 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
@@ -23,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -34,6 +34,8 @@ export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -61,7 +63,7 @@ export function NewSessionView(props: {
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<Show when={props.project.empty()}>
@@ -99,7 +101,15 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<ProviderTip />
<NewSessionTips
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
props.workspace.selection.value() !== "create" &&
props.workspace.project.managed() === 0
}
onWorkspace={() => select("create")}
/>
</div>
</div>
)
@@ -118,56 +128,90 @@ export function NewSessionStatus(props: { visible: boolean }) {
)
}
function ProviderTip() {
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const visible = createMemo(
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
Persist.global("new-session.workspace-tip"),
WorkspaceTipSchema,
{ dismissedAt: 0 },
)
const workspaceVisible = createMemo(
() =>
props.workspaceEligible &&
workspaceReady() &&
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
)
const providerVisible = createMemo(
() =>
providers.ready() &&
persistedReady() &&
providerReady() &&
providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
if (providerVisible()) return "provider"
if (workspaceVisible()) return "workspace"
})
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: visible,
show: () => tip() !== undefined,
element: () => ref() ?? null,
})
const openProviders = () => {
const open = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
return
}
setProviderState("dismissedAt", Date.now())
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
data-component="new-session-tip"
data-visible={tip() !== undefined}
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={openProviders}
onClick={open}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="truncate">
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
@@ -176,7 +220,7 @@ function ProviderTip() {
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
onClick={dismiss}
>
<Icon name="xmark-small" />
</button>
+43
View File
@@ -0,0 +1,43 @@
[data-component="new-session-wordmark"] {
--wordmark-shimmer-opacity: 0.45;
.wordmark-shimmer {
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
color: white;
mix-blend-mode: screen;
opacity: 0;
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
animation: new-session-wordmark-shimmer 800ms linear both;
}
}
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
--wordmark-shimmer-opacity: 0.0672;
}
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
@keyframes new-session-wordmark-shimmer {
0% {
mask-position: 100% 0;
opacity: 0;
}
20%,
80% {
opacity: var(--wordmark-shimmer-opacity);
}
100% {
mask-position: 0% 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="new-session-wordmark"] {
.wordmark-shimmer {
animation: none;
}
}
}
@@ -0,0 +1,11 @@
import { NewSessionWordmark } from "./wordmark"
export default {
title: "App/New Session/Wordmark",
id: "app-new-session-wordmark",
component: NewSessionWordmark,
}
export const Reveal = {
render: () => <NewSessionWordmark />,
}
+17
View File
@@ -0,0 +1,17 @@
import { Wordmark } from "@opencode-ai/ui/wordmark"
import "./wordmark.css"
export function NewSessionWordmark() {
return (
<div
data-component="new-session-wordmark"
aria-hidden="true"
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
>
<div data-slot="wordmark-reveal" class="relative">
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
@@ -56,6 +56,50 @@ export function createNewSessionWorkspaceController(input: {
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const worktreeSource = createMemo(
() => {
const project = currentProject()
return project ? { projectID: project.id, directory: project.worktree } : undefined
},
undefined,
{ equals: (a, b) => a?.projectID === b?.projectID && a?.directory === b?.directory },
)
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
projectID: source.projectID,
items: await serverSDK.api.worktree
.list({ location: { directory: source.directory } })
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
}))
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
}),
)
const worktreeItems = createMemo(() => {
const project = currentProject()
if (!project) return []
const loaded = worktrees.latest
return loaded?.projectID === project.id ? loaded.items : project.worktrees
})
const worktreeDirectories = createMemo(() => {
const project = currentProject()
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
})
const managedWorktrees = createMemo(() => {
const project = currentProject()
if (!project) return 0
return worktreeItems().filter(
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
).length
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
@@ -66,7 +110,10 @@ export function createNewSessionWorkspaceController(input: {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
return isWorkspaceSelection(project, worktree) ||
worktreeDirectories().some((item) => sameDirectory(item, worktree))
? worktree
: undefined
})
const fallback = createMemo(() => {
const project = currentProject()
@@ -97,7 +144,7 @@ export function createNewSessionWorkspaceController(input: {
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const branch = createMemo(() =>
@@ -122,7 +169,12 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
return (
current === "create" ||
(!!project &&
(isWorkspaceDirectory(project, current) ||
worktreeDirectories().some((item) => sameDirectory(item, current))))
)
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -142,10 +194,8 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
},
workspaces: worktreeDirectories,
managed: managedWorktrees,
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
@@ -1,5 +1,6 @@
import { createMemo, For, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -25,6 +26,9 @@ export function PromptWorkspaceSelector(props: {
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
@@ -34,8 +38,8 @@ export function PromptWorkspaceSelector(props: {
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
if (selected() === "create") return "plus"
return "outline-worktree"
}
const select = (value: string) => {
pending = { type: "select", value }
@@ -66,13 +70,14 @@ export function PromptWorkspaceSelector(props: {
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
@@ -89,7 +94,10 @@ export function PromptWorkspaceSelector(props: {
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
@@ -109,14 +117,8 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
@@ -126,24 +128,22 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
</Menu.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</>
}
>
<Show when={props.workspaces.length > 0}>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
@@ -156,10 +156,11 @@ export function PromptWorkspaceSelector(props: {
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
focusWorktreeSearch()
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -170,7 +171,7 @@ export function PromptWorkspaceSelector(props: {
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
@@ -205,7 +206,7 @@ export function PromptWorkspaceSelector(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
@@ -213,6 +214,11 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
@@ -232,6 +238,7 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
@@ -245,7 +252,7 @@ export function PromptWorkspaceSelector(props: {
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
<span ref={branchTruncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@@ -319,6 +326,7 @@ export function PromptWorkspaceSelector(props: {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
@@ -338,15 +346,27 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
</div>
</Tooltip>
)}
</Show>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}
+42 -14
View File
@@ -659,6 +659,7 @@ export const dict = {
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.files": "Files",
@@ -676,7 +677,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.moveRunning": "Move to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -736,6 +737,16 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
@@ -759,6 +770,8 @@ export const dict = {
"session.header.search.placeholder": "Search {{project}}",
"session.header.searchFiles": "Search files",
"session.search.placeholder": "Find...",
"session.search.noResults": "No matches",
"session.header.openIn": "Open in",
"session.header.open.action": "Open {{app}}",
"session.header.open.ariaLabel": "Open in {{app}}",
@@ -1045,8 +1058,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.showProjectIcon.title": "Show project icon",
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
"settings.general.row.mobileDiffWrap.description":
@@ -1238,30 +1251,43 @@ export const dict = {
"workspace.new": "New worktree",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.local.tooltip": "Uses projects current checkout",
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No worktrees",
"settings.workspaces.empty": "No worktrees yet",
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
"settings.workspaces.count.one": "{{count}} worktree",
"settings.workspaces.count.other": "{{count}} worktrees",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.sessions.filtered.one": "{{count}} session",
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all worktrees",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
"settings.workspaces.delete.warning":
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
"settings.workspaces.deleteWithoutSessions.confirm.one":
"This will permanently delete {{count}} worktree without linked sessions.",
"settings.workspaces.deleteWithoutSessions.confirm.other":
"This will permanently delete {{count}} worktrees without linked sessions.",
"settings.workspaces.deleteWithoutSessions.warning":
"Worktrees with unmerged changes or active locations will be kept.",
"settings.workspaces.delete.button": "Delete worktrees",
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.warning":
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will be permanently removed.",
"Linked sessions will remain, but their working directory will no longer exist.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -1272,9 +1298,10 @@ export const dict = {
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating worktree",
"workspace.lifecycle.created": "Worktree created",
"workspace.lifecycle.deleting": "Deleting…",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with worktrees",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.onboarding.description": "Each gets its own checkout",
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
@@ -1293,9 +1320,10 @@ export const dict = {
"workspace.status.checking": "Checking for unmerged changes…",
"workspace.status.error": "Unable to verify git status.",
"workspace.status.clean": "No unmerged changes detected.",
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
"workspace.status.dirty": "Unmerged changes will be lost.",
"workspace.delete.title": "Delete worktree",
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
"workspace.delete.confirm": "Delete {{name}}”?",
"workspace.delete.location": "Location",
"workspace.delete.button": "Delete worktree",
"workspace.reset.title": "Reset worktree",
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
@@ -49,6 +49,36 @@ migration rules remain explicit in their schemas.
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Writes
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
hides, and the write is skipped when the serialized form did not change. Reactive observers
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
change wins over a value arriving from another window, and over a stored value that finishes
loading after the user already edited. A remote value that arrives while the store is dirty
is held until the save runs; if the local setter calls turned out not to change the
serialized form, the remote value is adopted instead of being lost.
## Namespaces
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
namespace is loaded from the host once, reads are Map lookups from then on, and writes
update the cache immediately while being batched into one host round trip per flush
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
flush on page hide is on the wire before the page goes away; the desktop platform flushes
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
local write carries a sequence number that is kept until the host accepts that exact write;
until then neither the initial load, a change from another window (`accept`), nor the retry
of an older failed batch can replace the key. The host also stamps every update with a
monotonic revision, returned in the ack and carried by change events and loads, so an event
that reaches a window after a newer ack or load for the same key is recognised as stale and
dropped; an event held back during an in-flight write is applied after the ack when the host
ordered it later.
## Migrations
Describe shipped representations with schemas and transform their typed values
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
@@ -12,6 +12,7 @@ describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -19,6 +20,7 @@ describe("persisted consumer schemas", () => {
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
@@ -0,0 +1,247 @@
import { describe, expect, test } from "bun:test"
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
// that the test delivers to other windows whenever it chooses, like the real event stream.
function host(initial: Record<string, Record<string, string>> = {}) {
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
const calls: Call[] = []
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
let revision = 0
let fail: (insert: Record<string, string>) => boolean = () => false
let gate: Promise<void> | undefined
const driver: NamespaceDriver = {
items: async (name) => {
calls.push({ kind: "items", name })
return { items: Object.fromEntries(data.get(name) ?? []), revision }
},
update: async (name, insert, remove) => {
calls.push({ kind: "update", name, insert, remove })
await gate
if (fail(insert)) throw new Error("disk full")
const items = data.get(name) ?? new Map()
for (const [key, value] of Object.entries(insert)) items.set(key, value)
for (const key of remove) items.delete(key)
data.set(name, items)
events.push({ name, insert, remove, revision: ++revision })
return revision
},
clear: async (name) => {
calls.push({ kind: "clear", name })
data.delete(name)
},
}
return {
driver,
data,
calls,
events,
updates: () => calls.filter((call) => call.kind === "update"),
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
(fail = typeof value === "boolean" ? () => value : value),
setGate: (value: Promise<void> | undefined) => (gate = value),
deliver: (target: NamespaceStorage, index: number) => {
const event = events[index]!
target.accept(event.insert, event.remove, event.revision)
},
}
}
describe("namespace storage", () => {
test("loads the namespace once and serves reads from memory", async () => {
const h = host({ w: { tabs: "[]", recent: "{}" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
expect(await storage.getItem("tabs")).toBe("[]")
expect(await storage.getItem("recent")).toBe("{}")
expect(await storage.getItem("missing")).toBeNull()
expect(await storage.getLength()).toBe(2)
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
})
test("reads its own writes immediately and coalesces them into one update", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
void storage.setItem("tabs", "[1,2]")
void storage.removeItem("recent")
expect(await storage.getItem("tabs")).toBe("[1,2]")
expect(await storage.getItem("recent")).toBeNull()
expect(h.updates()).toHaveLength(0)
await wait(30)
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
})
test("writes made while loading win over the loaded snapshot", async () => {
const h = host({ w: { tabs: "old" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
const read = storage.getItem("tabs")
void storage.setItem("tabs", "new")
expect(await read).toBe("new")
})
test("flush writes now and resolves after the driver accepted the batch", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
await storage.flush()
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
await storage.flush()
expect(h.updates()).toHaveLength(1)
})
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
h.setFail(true)
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
await storage.flush()
expect(h.data.get("w")).toBeUndefined()
h.setFail(false)
void storage.setItem("tabs", "[2]")
await storage.flush()
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
})
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
void storage.flush()
void storage.setItem("tabs", "[2]")
void storage.flush()
// Both batches reached the driver while the first reply is still outstanding.
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
first.resolve()
await storage.flush()
})
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const accepted = Promise.withResolvers<number>()
const driver: NamespaceDriver = {
items: () => loaded.promise,
update: () => accepted.promise,
clear: async () => undefined,
}
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
void storage.setItem("model", "local")
void storage.flush()
storage.accept({ model: "other-window-older" }, [], 1)
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
expect(await read).toBe("local")
accepted.resolve(2)
await storage.flush()
expect(await storage.getItem("model")).toBe("local")
storage.accept({ model: "other-window-newer" }, [], 3)
expect(await storage.getItem("model")).toBe("other-window-newer")
})
test("a failed batch does not requeue a value a later batch already replaced", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
h.setFail((insert) => insert.tabs === "old")
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "old")
void storage.flush()
h.setGate(undefined)
void storage.setItem("tabs", "new")
void storage.flush()
await wait(0)
expect(h.data.get("w")?.get("tabs")).toBe("new")
first.resolve()
await storage.flush()
await storage.flush()
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
expect(h.data.get("w")?.get("tabs")).toBe("new")
expect(await storage.getItem("tabs")).toBe("new")
})
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
const h = host({ g: { model: "start" } })
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await one.getItem("model")
await two.getItem("model")
void one.setItem("model", "A")
await one.flush()
void two.setItem("model", "B")
await two.flush()
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
h.deliver(two, 0)
expect(await two.getItem("model")).toBe("B")
expect(h.data.get("g")?.get("model")).toBe("B")
// One still receives B, which is newer than its own ack.
h.deliver(one, 1)
expect(await one.getItem("model")).toBe("B")
})
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
const h = host()
const gate = Promise.withResolvers<void>()
h.setGate(gate.promise)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
void storage.setItem("model", "mine")
void storage.flush()
// Another window's write for the same key landed at the host after ours will.
storage.accept({ model: "theirs" }, [], 2)
expect(await storage.getItem("model")).toBe("mine")
gate.resolve()
await storage.flush()
expect(await storage.getItem("model")).toBe("theirs")
})
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
storage.accept({ model: "inserted" }, [], 41)
loaded.resolve({ items: {}, revision: 42 })
expect(await read).toBeNull()
// The delete event is older than the floor and must stay a no-op either way.
storage.accept({}, ["model"], 42)
expect(await storage.getItem("model")).toBeNull()
// A key inserted by an event newer than the snapshot survives the load.
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
const pending = other.getItem("model")
other.accept({ model: "after-snapshot" }, [], 43)
second.resolve({ items: {}, revision: 42 })
expect(await pending).toBe("after-snapshot")
})
test("an event older than the initial load is ignored", async () => {
const h = host({ g: { model: "loaded" } })
void h.driver.update("g", { model: "loaded" }, [])
await wait(0)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await storage.getItem("model")
storage.accept({ model: "before-load" }, [], 1)
expect(await storage.getItem("model")).toBe("loaded")
storage.accept({}, ["model"], 2)
expect(await storage.getItem("model")).toBeNull()
})
test("clear drops the cache and queued changes and clears the driver", async () => {
const h = host({ w: { tabs: "[]" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
await storage.getItem("tabs")
void storage.setItem("recent", "{}")
await storage.clear()
expect(await storage.getItem("tabs")).toBeNull()
expect(await storage.getItem("recent")).toBeNull()
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
})
})
@@ -0,0 +1,159 @@
import type { AsyncStorage } from "@solid-primitives/storage"
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
// update with a monotonic revision and reports it with reads, acks, and change events.
export type NamespaceDriver = {
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
clear(name: string): Promise<void>
}
export type NamespaceStorage = AsyncStorage & {
/** Write every queued change now. Resolves when the driver has accepted it. */
flush(): Promise<void>
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
accept(insert: Record<string, string>, remove: string[], revision: number): void
}
export const namespaceFlushDelay = 100
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
// on; writes update the cache immediately and are batched into one driver call per flush window,
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
//
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
// recorded until the host acks that exact write; while recorded, nothing external may replace
// the key. Every value the cache holds also carries the host revision it came from, so an event
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
// on the wire before the page goes away.
export function createNamespaceStorage(
driver: NamespaceDriver,
name: string,
options: { delay?: number } = {},
): NamespaceStorage {
const delay = options.delay ?? namespaceFlushDelay
const cache = new Map<string, string>()
const local = new Map<string, { seq: number; value: string | null }>()
const dirty = new Set<string>()
const inflight = new Set<Promise<void>>()
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
const applied = new Map<string, number>()
let floor = -1
// The newest external change for a key that arrived while a local write was still in flight.
const deferred = new Map<string, { revision: number; value: string | null }>()
let seq = 0
let loading: Promise<void> | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const place = (key: string, value: string | null, revision: number) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
applied.set(key, revision)
}
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
// an older event inserted it into the cache while the load was in flight.
const load = () =>
(loading ??= driver.items(name).then((loaded) => {
floor = loaded.revision
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
for (const key of [...cache.keys()]) {
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
}
for (const [key, value] of Object.entries(loaded.items)) {
if (stale(key)) place(key, value, loaded.revision)
}
}))
const write = (key: string, value: string | null) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
local.set(key, { seq: ++seq, value })
dirty.add(key)
timer ??= setTimeout(() => void flush(), delay)
}
// The host accepted this window's value for `key` at `revision`. A change from another window
// that was held back meanwhile wins if the host applied it later than ours.
const acknowledge = (key: string, revision: number) => {
local.delete(key)
const later = deferred.get(key)
deferred.delete(key)
if (later && later.revision > revision) return place(key, later.value, later.revision)
applied.set(key, revision)
}
const flush = () => {
clearTimeout(timer)
timer = undefined
if (dirty.size > 0) {
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
dirty.clear()
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
const request = driver
.update(name, insert, remove)
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
.catch((error: unknown) => {
// Only a value nothing newer has replaced is worth retrying.
batch.filter(current).forEach((entry) => dirty.add(entry.key))
console.error(`[persistence] flush failed for ${name}`, error)
})
.finally(() => inflight.delete(request))
inflight.add(request)
}
return Promise.all(inflight).then(() => undefined)
}
const storage: NamespaceStorage = {
getItem: async (key) => {
await load()
return cache.get(key) ?? null
},
setItem: async (key, value) => write(key, value),
removeItem: async (key) => write(key, null),
clear: async () => {
clearTimeout(timer)
timer = undefined
cache.clear()
local.clear()
dirty.clear()
applied.clear()
deferred.clear()
loading = Promise.resolve()
await driver.clear(name)
},
key: async (index: number) => {
await load()
return [...cache.keys()][index]
},
getLength: async () => {
await load()
return cache.size
},
get length() {
return storage.getLength()
},
flush,
accept(insert, remove, revision) {
// The initial load already reflects everything up to `floor`.
if (revision <= floor) return
const changes = [
...Object.entries(insert).map(([key, value]) => [key, value] as const),
...remove.map((key) => [key, null] as const),
]
for (const [key, value] of changes) {
if (local.has(key)) {
const held = deferred.get(key)
if (!held || revision > held.revision) deferred.set(key, { revision, value })
continue
}
if (revision <= (applied.get(key) ?? floor)) continue
place(key, value, revision)
}
},
}
return storage
}
@@ -0,0 +1,156 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { flushPersisted, persistStore } from "./persist"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type State = { count: number; label: string }
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
const writes: string[] = []
return createRoot((dispose) => {
const [store, setStore] = createStore<State>({ count: 0, label: "" })
const persist = persistStore({
store,
setStore,
name: "state",
// One fixture serves both the sync and the async storage shape.
storage: {
getItem: () => input.initial ?? null,
setItem: (_key: string, value: string) => {
writes.push(value)
},
removeItem: () => {},
} as SyncStorage | AsyncStorage,
serialize: JSON.stringify,
deserialize: JSON.parse,
sync: input.sync,
delay: input.delay ?? 10,
})
return { store, set: persist.setStore, persist, writes, dispose }
})
}
describe("persistStore", () => {
test("marks the store dirty on set and writes once after the delay", async () => {
const value = setup({})
value.set("count", 1)
value.set("count", 2)
value.set("label", "a")
expect(value.store.count).toBe(2)
expect(value.writes).toEqual([])
await wait(30)
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
value.dispose()
})
test("skips the write when the serialized value did not change", () => {
const value = setup({ delay: 10_000 })
value.set("count", 1)
value.persist.flush()
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("hydrates synchronously from sync storage without writing back", () => {
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
expect(value.store).toEqual({ count: 5, label: "saved" })
value.persist.flush()
expect(value.writes).toEqual([])
value.dispose()
})
test("a set made while async storage loads wins over the loaded value", async () => {
const loading = Promise.withResolvers<string | null>()
const value = setup({ initial: loading.promise, delay: 10_000 })
value.set("count", 9)
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
await loading.promise
expect(value.store.count).toBe(9)
value.dispose()
})
test("applies another window's value when clean and ignores it while dirty", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "remote" })
value.set("label", "local")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "local" })
value.persist.flush()
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
value.dispose()
})
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
value.set("count", 1)
value.persist.flush()
// Setting the same value again marks the store dirty without changing it.
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
expect(value.store.label).toBe("")
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "remote" })
expect(value.writes).toHaveLength(1)
expect(sent).toHaveLength(1)
// A later save must not consider the adopted value a local change.
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("label", "saved")
value.persist.flush()
value.set("label", "saved")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 0, label: "saved" })
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "" })
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
value.dispose()
})
test("disposing the owner and flushPersisted both save pending changes", () => {
const first = setup({ delay: 10_000 })
first.set("count", 1)
first.dispose()
expect(first.writes).toHaveLength(1)
const second = setup({ delay: 10_000 })
second.set("count", 2)
flushPersisted()
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
second.dispose()
expect(second.writes).toHaveLength(1)
})
})
@@ -0,0 +1,100 @@
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
import { getOwner, onCleanup, untrack } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
export const persistSaveDelay = 100
const pending = new Set<() => void>()
/** Serialize and write every store with unsaved changes now. */
export function flushPersisted() {
for (const save of [...pending]) save()
}
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushPersisted()
})
window.addEventListener("pagehide", flushPersisted)
}
// A store whose serialized form is written to storage on a schedule instead of on every setter
// call. The setter only marks the store dirty; serialization happens once per save window, once
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
export function persistStore<T extends object>(input: {
store: Store<T>
setStore: SetStoreFunction<T>
name: string
storage: SyncStorage | AsyncStorage
serialize: (value: T) => string
deserialize: (raw: string) => T
sync?: PersistenceSyncAPI
delay?: number
}) {
const delay = input.delay ?? persistSaveDelay
let dirty = false
let touched = false
let last: string | undefined
// The newest value another window wrote while this store was dirty; applied at save time if the
// local setter calls turned out not to change anything.
let remote: string | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const save = () => {
clearTimeout(timer)
timer = undefined
pending.delete(save)
if (!dirty) return
dirty = false
const held = remote
remote = undefined
const next = untrack(() => input.serialize(input.store))
if (next === last) {
if (held !== undefined && held !== last) hydrate(held)
return
}
last = next
input.sync?.[1](input.name, next)
void input.storage.setItem(input.name, next)
}
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
const apply = input.setStore as unknown as (...values: unknown[]) => void
const setStore = ((...values: unknown[]) => {
apply(...values)
dirty = true
touched = true
pending.add(save)
timer ??= setTimeout(save, delay)
}) as unknown as SetStoreFunction<T>
const hydrate = (raw: string) => {
last = raw
input.setStore(reconcile(input.deserialize(raw)))
}
const init = input.storage.getItem(input.name)
// A value the user already changed is newer than whatever storage held.
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
else if (init) hydrate(init)
input.sync?.[0]((data) => {
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
if (!data.newValue) return
// A real unsaved local change wins over another window's write, as in VS Code's storage
// service; whether the change is real is only known when the store is serialized. Every
// remote value replaces the held one, including a revert to `last`, so the save sees the
// other window's final state rather than an intermediate one.
if (dirty) {
remote = data.newValue
return
}
if (data.newValue === last) return
hydrate(data.newValue)
})
if (getOwner()) onCleanup(save)
return { setStore, init, flush: save }
}
@@ -1,14 +1,15 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
type InitType = Promise<string> | string | null
type InitType = Promise<string | null> | string | null
type PersistedWithReady<T> = [
Store<T>,
SetStoreFunction<T>,
@@ -593,13 +594,18 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
const persist = persistStore({
store: store[0],
setStore: store[1],
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
})
const state = store[0]
const setState = persist.setStore
const init = persist.init
const isAsync = init instanceof Promise
const [ready] = createResource(
+3 -1
View File
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
import { createRefCountMap } from "@/runtime/server/refcount"
import { createRequestQueue } from "@/runtime/server/request-queue"
import { ServerScope } from "@/runtime/server/scope"
import { useServer } from "./current"
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
const api = createApiForServer({ server: http, fetch: queue.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
@@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
@@ -20,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
@@ -47,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
// A refetch keeps the inventory a view already loaded for this project.
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
expect(store.project[0]?.worktrees).toEqual([
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
} finally {
queryClient.clear()
}
@@ -76,70 +88,39 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads each project's inventory through its own location using the real client", async () => {
const calls: string[] = []
test("loads project metadata without enumerating any project's worktrees", async () => {
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
calls.push(directory)
requests.push(url.pathname)
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
return Response.json([
{ directory },
{ directory: `${directory}/clone` },
{ directory: `${directory}/copy`, strategy: "git" },
...Array.from({ length: 300 }, (_, index) => ({
id: `historical-${index.toString().padStart(3, "0")}`,
canonical: `/history/${index}`,
time: { created: 1, updated: 1 },
sandboxes: [],
})),
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["/a", "/b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
return Response.json([{ directory: "/a/copy", strategy: "git" }])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
expect(requests).toEqual(["/api/project"])
expect(result).toHaveLength(302)
expect(result.slice(0, 2)).toMatchObject([
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
])
expect(result.some((project) => project.id === "test")).toBe(false)
})
})
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -64,42 +63,21 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ location: { directory: project.canonical } })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
projects.list().then((items) =>
items
.filter((project) => !!project?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
.sort((a, b) => cmp(a.id, b.id)),
),
),
})
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
serverAPI: {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
),
),
),
),
]
await runAll(slow)
}
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[key] !== child) return
@@ -0,0 +1,207 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
function setup(input?: {
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
}) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource, init)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
})
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
}
describe("createRequestQueue", () => {
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
expect(input.queue.queued()).toBe(1)
input.pending[0]!.resolve()
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = [
"/api/vcs?location[directory]=%2Fa",
"/api/vcs/diff?location[directory]=%2Fa",
"/api/worktree",
"/api/session/ses_1",
]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
expect(input.queue.inflight()).toBe(3)
expect(input.queue.queued()).toBe(1)
// A fast request finishing does not free a slow slot.
input.pending[2]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
input.pending[0]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
void input.queue.fetch("http://server/api/event")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
expect(input.queue.inflight()).toBe(1)
})
test("aborted requests leave the queue without being sent", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
void input.queue.fetch("http://server/api/first")
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
controller.abort()
await input.settle()
input.pending[0]!.resolve()
await expect(aborted).rejects.toBeInstanceOf(DOMException)
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
expect(input.queue.inflight()).toBe(0)
})
test("a request the server never answers times out and frees its slot", async () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
const listError = await list.catch((cause: unknown) => cause)
expect((listError as DOMException).name).toBe("TimeoutError")
// Past the normal deadline, the create is still on the wire.
expect(input.pending[0]!.signal.aborted).toBe(false)
input.pending[0]!.resolve()
await expect(create).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("only worktree creation counts as a setup request", () => {
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
await input.settle()
expect(input.pending[0]!.signal.aborted).toBe(false)
controller.abort()
expect(input.pending[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toBeInstanceOf(DOMException)
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
await input.settle()
expect(input.queue.queued()).toBe(10)
// Drain two at a time before the stall threshold elapses.
for (let round = 0; round < 6; round++) {
input.pending.splice(0).forEach((item) => item.resolve())
await input.settle()
}
await Promise.all(responses)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([])
})
test("logs what is in flight and queued once per burst after requests stall", async () => {
const input = setup({ stallMs: 5 })
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/health").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([
{
message: "server thrashing detected",
data: {
limit: 2,
inflight: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
],
},
},
])
// Still stalled within the rate limit: no repeat.
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(1)
input.tick(10_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(2)
})
})
@@ -0,0 +1,131 @@
type Entry = { method: string; url: string; at: number; slow: boolean }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
// a burst stalls probes and user actions inside the browser where nothing can observe it.
export const requestQueueLimit = 4
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
// may hold at most this many slots, so a session mount's small reads never queue behind them.
export const requestQueueSlowLimit = 2
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
// kills the script midway and leaves a registered but half-initialised worktree behind.
export const setupRequestHeadersTimeoutMs = 10 * 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function isSetupRequest(method: string, pathname: string) {
return method === "POST" && pathname === "/api/worktree"
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
const inflight = new Set<Entry>()
const waiting: Array<{ entry: Entry; start: () => void }> = []
let warned = -Infinity
let watcher: ReturnType<typeof setTimeout> | undefined
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
// Debug exports include the console, so list what the server is busy with while requests wait.
const watch = () => {
watcher = undefined
const oldest = waiting[0]?.entry
if (!oldest) return
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
warned = now()
log("server thrashing detected", {
limit,
inflight: [...inflight].map(describe),
queued: waiting.map((item) => describe(item.entry)),
})
}
watcher = setTimeout(watch, stallMs)
}
const canStart = (entry: Entry) => {
if (inflight.size >= limit) return false
if (!entry.slow) return true
return [...inflight].filter((item) => item.slow).length < slowLimit
}
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
const release = (entry: Entry) => {
inflight.delete(entry)
const index = waiting.findIndex((item) => canStart(item.entry))
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
const pathname = new URL(request.url).pathname
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
const controller = new AbortController()
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
release(entry)
})
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
)
return {
fetch,
inflight: () => inflight.size,
queued: () => waiting.length,
}
}
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
import { createConnectionSync, reconnectOrder } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
})
dispose()
})
test("held directories refresh before the rest, otherwise keeping their order", () => {
const held = new Set(["/b", "/d"])
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
})
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
return { handleEvent }
}
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
}
+33 -11
View File
@@ -17,9 +17,11 @@ import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
import type { ServerApi } from "@/runtime/server/api"
import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const worktrees = createWorktreeInventory({
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
),
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
@@ -149,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
},
connected: (info) => {
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
void data.location.sync({ directory }).catch(() => undefined)
})
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
// directory here as well sent the whole catalog fan-out for all of them at once.
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
(directory) => queue.push(directory),
)
},
})
@@ -197,17 +209,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
projects.map((project) =>
project.id === update.id
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
: project,
),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
if (root) void worktrees.refresh(root)
void bootstrap.refetch()
return
}
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
return
}
@@ -216,7 +238,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
})
onCleanup(unsub)
@@ -261,6 +282,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
worktrees,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
@@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
+62 -23
View File
@@ -24,6 +24,11 @@ import {
type FileTreeV2Node,
} from "@/session/files/file-tree-v2-model"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
export type { Kind } from "@/session/files/file-tree"
@@ -99,7 +104,7 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
@@ -136,6 +141,9 @@ export default function FileTreeV2(props: {
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
@@ -217,6 +225,19 @@ export default function FileTreeV2(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
rows()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
}
>
<FileTreeNodeV2
@@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
</div>
)
}
export function syncFileTreeV2Width(element?: HTMLDivElement) {
if (!element) return
queueMicrotask(() => {
if (!element.isConnected) return
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
if (width <= element.clientWidth) return
element.style.width = `${width}px`
})
}
+56 -34
View File
@@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -49,6 +54,9 @@ export function SessionFileList(props: {
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
const normalized = createMemo(() => props.files.map(normalizePath))
@@ -89,6 +97,19 @@ export function SessionFileList(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
normalized()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -114,47 +135,48 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</button>
</OpenInAppContextMenuV2>
</div>
)}
</Show>
@@ -1,4 +1,4 @@
import { For, Show } from "solid-js"
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
const state = useOpenInApp({ path: props.directory })
return (
<Show when={props.directory() && state.canOpen()}>
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
state.openPath(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
describe("resolveOpenInAppPath", () => {
test("joins relative paths using the workspace separator", () => {
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
})
test("does not duplicate root separators", () => {
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
})
test("preserves backslashes in POSIX filenames", () => {
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
})
test("preserves absolute POSIX, Windows, and UNC paths", () => {
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
})
})
describe("openInAppParentPath", () => {
test("preserves POSIX and Windows roots", () => {
expect(openInAppParentPath("/file.ts")).toBe("/")
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
})
})
@@ -0,0 +1,19 @@
export function resolveOpenInAppPath(root: string, path: string) {
if (!path) return root
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
if (!root) return path
const separator = root.includes("\\") ? "\\" : "/"
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
}
export function openInAppParentPath(path: string) {
const value = path.replace(/[\\/]+$/, "")
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
if (index < 0) return path
if (index === 0) return value.slice(0, 1)
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
+30 -21
View File
@@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
export const OPEN_APPS = [
"vscode",
@@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
return fileManagerApp(os)
}
export function openAppsForOS(os: OpenAppOS) {
@@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
export function useOpenInApp(input: { directory: () => string }) {
export function useOpenInApp(input: { path: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
@@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) {
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
@@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) {
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
if (!target) return
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
const request =
app === "finder" && reveal && platform.revealPath
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
: open(target, openWith)
request
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
const copyPath = (target = input.path()) => {
if (!target) return
navigator.clipboard
.writeText(directory)
.writeText(target)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: directory,
description: target,
})
})
.catch((err: unknown) => showRequestError(language, err))
@@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) {
options,
menu,
setMenu,
openDir,
openPath,
selectApp,
copyPath,
}
}
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
const request = Promise.resolve(platform.checkAppExists?.(app))
.then(Boolean)
.catch(() => false)
appExistence.set(app, request)
return request
}
+35 -5
View File
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
data.session.form.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
})
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory, workspace: session.location.workspaceID },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
websearch,
permissionRequest,
permissionResponding,
background: {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -23,6 +23,22 @@ const question = (id: string, sessionID: string) =>
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
describe("sessionTreeIDs", () => {
test("returns only the current session and its descendants", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
session({ id: "sibling", parentID: "root" }),
session({ id: "other" }),
]
expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
expect(sessionTreeIDs(sessions)).toEqual([])
})
})
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
@@ -81,7 +97,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionForm", () => {
describe("sessionFormRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -89,7 +105,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -102,15 +118,29 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips forms that are not questions", () => {
test("skips unsupported forms", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
})
test("finds web search consent in a nested child session", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
})
test("preserves request order across questions and web search", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
})
})
@@ -6,8 +6,16 @@ function sessionTreeRequest<T>(
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
if (!sessionID) return
const ids = sessionTreeIDs(session, sessionID)
if (!ids.length) return
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
}
export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
if (!sessionID) return []
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
@@ -27,11 +35,7 @@ function sessionTreeRequest<T>(
ids.push(child)
}
}
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
return ids
}
export function sessionPermissionRequest(
@@ -43,10 +47,15 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionForm(
export function sessionFormRequest(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string,
) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
return sessionTreeRequest(
session,
request,
sessionID,
(item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
)
}
@@ -0,0 +1,58 @@
[data-component="session-websearch-dock"] {
width: 100%;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 12px;
.websearch-body {
box-shadow: var(--v2-elevation-raised);
}
.websearch-setting {
padding: 16px;
}
.websearch-setting [data-component="settings-row"] {
column-gap: 24px;
padding-block: 0;
border: 0;
}
.websearch-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 24px 16px 12px;
border: 0;
}
.websearch-status {
display: flex;
align-items: center;
gap: 8px;
margin-inline-end: auto;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
.websearch-status:empty {
display: none;
}
.websearch-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
}
[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
width: max-content;
min-width: 0;
[data-component="menu-v2-item"] {
gap: 24px;
}
}
@@ -0,0 +1,86 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Select } from "@opencode-ai/ui/select"
import { useLanguage } from "@/runtime/i18n/language"
import { SettingsRow } from "@/settings/row"
import type { WebSearchRequestModel } from "./websearch"
import "./session-websearch-dock.css"
export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
const language = useLanguage()
const options = createMemo(() => [
...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
...props.model.options(),
])
const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
const busy = () => props.model.sending() || !props.model.connected()
const status = () => {
if (props.model.loading()) return language.t("common.loading")
if (props.model.failed()) return language.t("session.websearch.failed")
if (!props.model.options().length) return language.t("session.websearch.empty")
}
const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
const submit = (selection: string | false) => {
if (busy()) return
props.onSubmit()
void props.model.submit(selection)
}
return (
<section
data-component="session-websearch-dock"
aria-label={language.t("session.websearch.title")}
aria-busy={props.model.sending()}
>
<DockShell class="websearch-body">
<div class="websearch-setting">
<SettingsRow
title={language.t("session.websearch.title")}
description={language.t("session.websearch.description")}
>
<Select
aria-label={language.t("session.websearch.provider")}
options={options()}
current={current()}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && props.model.select(option.value)}
disabled={busy() || unavailable()}
placeholder={language.t("session.websearch.provider")}
contentClass="websearch-provider-menu"
/>
</SettingsRow>
</div>
</DockShell>
<DockTray attach="top" class="websearch-footer">
<div class="websearch-status" aria-live="polite">
<Show when={props.model.loadFailed()} fallback={status()}>
<span>{language.t("session.websearch.loadFailed")}</span>
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
{language.t("session.websearch.retry")}
</Button>
</Show>
</div>
<div class="websearch-actions">
<Show when={!props.model.specific()}>
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
{language.t("session.websearch.disable")}
</Button>
</Show>
<Button
variant="neutral"
size="small"
onClick={() => {
const selected = props.model.selected()
if (selected) submit(selected)
}}
disabled={busy() || unavailable() || !current()}
>
{language.t("session.websearch.enable")}
</Button>
</div>
</DockTray>
</section>
)
}
@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [
{
key: "provider",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
}
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
form: consent,
signal: abort.signal,
abort,
listeners,
replies,
events: {
listen(listener: (event: OpenCodeEvent) => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
cancel: (id: string) =>
emit({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
answer: (id: string, answer: FormAnswer) =>
emit({
id: "evt_reply",
created: 0,
type: "form.replied",
data: { id, sessionID: consent.sessionID, answer },
}),
}
}
describe("web search desktop consent", () => {
test.each([
["random", "allow"],
[false, "disable"],
] as const)("submits %s without a second form", async (selection, choice) => {
const input = fixture()
await replyWebSearch({ ...input, selection })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
expect(input.listeners.size).toBe(0)
})
test("subscribes before the first reply and answers the owning session's provider form", async () => {
const input = fixture()
await replyWebSearch({
...input,
selection: "parallel",
reply: async (answer) => {
input.replies.push(answer)
if (answer.answer.choice === "choose") input.create()
},
})
expect(input.replies).toEqual([
{ sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
])
expect(input.listeners.size).toBe(0)
})
test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
input.create({ ...provider, sessionID: "ses_other" })
input.create({ ...provider, metadata: { kind: "question" } })
input.create(consent)
input.create()
await pending
expect(input.replies).toHaveLength(2)
expect(input.replies[1]?.formID).toBe(provider.id)
})
test("leaves a changed provider list for explicit confirmation", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "removed" })
input.create()
await pending
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("supports direct entry into the provider form", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: "exa" })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
expect(input.listeners.size).toBe(0)
})
test("does not misrepresent cancelling the provider form as disabling search", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: false })
expect(input.replies).toEqual([])
})
test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
if (action === "cancel") input.cancel(consent.id)
if (action === "other-client") input.answer(consent.id, { choice: "disable" })
if (action === "abort") input.abort.abort()
await pending
input.create()
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("cleans up after a failed consent submission", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async () => {
throw new Error("offline")
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
test("propagates provider submission failures for retry", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async (answer) => {
if (answer.formID === provider.id) throw new Error("offline")
input.create()
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
})
@@ -0,0 +1,154 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
export function webSearchProviderField(form: FormInfo) {
return form.fields.find(
(field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
)
}
export function createWebSearchRequest(input: {
owner: () => string | undefined
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
selected: "random",
sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
error: false,
})
const [providers, resource] = createResource(input.request, async (form) => {
const field = webSearchProviderField(form)
if (field) return field.options ?? []
return input.providers(form.sessionID)
})
const request = createMemo(() => store.sending?.form ?? input.request())
const specific = createMemo(() => {
const form = request()
return !!form && !!webSearchProviderField(form)
})
const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
const selected = createMemo(() => {
if (!specific()) return store.selected
return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
})
createEffect(
on([input.owner, input.connected], () => {
store.sending?.abort.abort()
setStore({ sending: undefined, selected: "random", error: false })
}),
)
createEffect(
on(
() => input.request()?.id,
() => {
const form = input.request()
if (!form || store.sending || webSearchProviderField(form)) return
setStore({ selected: "random", error: false })
},
),
)
onCleanup(() => store.sending?.abort.abort())
const submit = async (selection: string | false) => {
const form = input.request()
if (!form || store.sending || !input.connected()) return
if (selection === false && webSearchProviderField(form)) return
const sending = { form, abort: new AbortController() }
setStore({ sending, error: false })
await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
.catch(() => {
if (!sending.abort.signal.aborted) setStore("error", true)
})
.finally(() => {
if (store.sending?.abort === sending.abort) setStore("sending", undefined)
})
}
return {
request,
options,
selected,
specific,
loading: () => providers.loading,
loadFailed: () => !!providers.error,
failed: () => store.error,
sending: () => !!store.sending,
connected: input.connected,
select: (value: string) => setStore({ selected: value, error: false }),
retry: () => void resource.refetch(),
submit,
}
}
export type WebSearchRequestModel = ReturnType<typeof createWebSearchRequest>
export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
if (webSearchProviderField(input.form)) {
if (input.selection === false) return
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { provider: input.selection },
})
}
if (input.selection === false || input.selection === "random") {
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { choice: input.selection === false ? "disable" : "allow" },
})
}
const next = Promise.withResolvers<FormInfo | undefined>()
const stop = input.events.listen((event) => {
if (event.type === "form.created") {
const form = event.data.form
if (
form.sessionID !== input.form.sessionID ||
form.id === input.form.id ||
form.metadata?.kind !== "websearch.provider" ||
!webSearchProviderField(form)
)
return
next.resolve(form)
}
if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
next.resolve(undefined)
})
const cancel = () => next.resolve(undefined)
input.signal.addEventListener("abort", cancel, { once: true })
return Promise.all([
input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
next.promise,
])
.then(([, form]) => {
if (!form || input.signal.aborted) return
const field = webSearchProviderField(form)
if (!field?.options?.some((option) => option.value === input.selection)) return
return input.reply({
sessionID: form.sessionID,
formID: form.id,
answer: { provider: input.selection },
})
})
.finally(() => {
stop()
input.signal.removeEventListener("abort", cancel)
})
}
+9
View File
@@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
@@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) {
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
sessionID: session.identity.sessionID,
scrollRef: timeline.scroller,
revealMessage: timeline.actions.revealMessage,
pauseAutoScroll: timeline.view.unpin,
})
const messagesReady = timeline.ready
const [store, setStore] = createStore({
deferRender: false,
@@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) {
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
</span>
}
>
@@ -2,30 +2,54 @@ import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
function store() {
const syncs = { session: 0, message: 0, pending: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
pending: {
sync: () => {
syncs.pending++
return Promise.resolve()
},
},
}
return { syncs, sessions }
}
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const input = store()
const session = createSessionResolution(
() => undefined,
() => sessions,
() => input.sessions,
)
expect(session()).toBeUndefined()
expect(syncs).toEqual({ session: 0, message: 0 })
expect(input.syncs).toEqual({ session: 0, message: 0, pending: 0 })
dispose()
})
})
test("starts the transcript and queued input reads with metadata", () => {
createRoot((dispose) => {
const input = store()
createSessionResolution(
() => "ses_open",
() => input.sessions,
{ children: true },
)
expect(input.syncs).toEqual({ session: 1, message: 1, pending: 1 })
dispose()
})
})
@@ -7,6 +7,9 @@ type SessionStore<T> = {
message: {
sync: (id: string) => Promise<unknown>
}
pending: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -52,8 +55,11 @@ export function createSessionResolution<T>(
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
// The timeline owns message errors; metadata resolution stays independent. Queued inputs
// ride along so a reconnect refreshes them with the transcript instead of leaving the
// pre-disconnect queue on screen.
void store.message.sync(id).catch(() => undefined)
void store.pending.sync(id).catch(() => undefined)
if (cached() && !options?.children && !options?.connected) {
setStatus({ id, store, state: "settled" })
return
@@ -138,6 +138,26 @@ export const QuestionRequest = {
),
}
export const WebSearchRequest = {
render: () => (
<SessionPreview
title="Search for current documentation"
description={description}
document={questionPendingDocument}
request={{
type: "websearch",
value: {
id: "frm_websearch_preview",
sessionID: "ses_websearch_preview",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
},
}}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
+25 -1
View File
@@ -23,6 +23,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -82,7 +83,7 @@ export type SessionPreviewProps = {
description: string
document: SessionDocument
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
@@ -173,10 +174,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
activity: string
reviewOpened: boolean
request: SessionPreviewProps["request"]
searchProvider: string
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
request: props.request,
searchProvider: "random",
})
const prompt = createPromptController({
initial: props.draft ?? "",
@@ -189,6 +192,27 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
websearch: {
request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
options: () => [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
{ value: "tavily", label: "Tavily" },
],
selected: () => state.searchProvider,
specific: () => false,
loading: () => false,
loadFailed: () => false,
failed: () => false,
sending: () => false,
connected: () => true,
select: (value) => setState("searchProvider", value),
retry() {},
submit: async (value) => {
setState("request", undefined)
setState("activity", `Web search selection (local only): ${value}`)
},
} satisfies WebSearchRequestModel,
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
@@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pinned: true,
},
refs: {
scroller: undefined as HTMLDivElement | undefined,
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
},
@@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
let revealMessage = (_id: string) => {}
let revealMessage = (_id: string, _partID?: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0
let messageMark = 0
@@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
setState("refs", "scroller", element)
if (!element) return
scheduleScrollState(element)
fill()
@@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
return {
actions: {
navigateMessage,
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
resume,
setActiveMessage,
},
@@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resource: timeline.resource,
ready: timeline.ready,
scroll: state.scroll,
scroller: () => scroller,
scroller: () => state.refs.scroller,
view: {
anchor,
markUserScroll,
@@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setDockRef: (element: HTMLDivElement | undefined) => {
setState("refs", "dock", element)
},
setRevealMessage: (reveal: (id: string) => void) => {
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
setScrollRef,
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
@@ -185,7 +184,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@@ -257,7 +256,10 @@ export function SessionSummaryPanel(props: {
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
@@ -354,8 +356,9 @@ type MessageTimelineProps = {
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
search?: JSX.Element
}
export function MessageTimeline(props: MessageTimelineProps) {
@@ -790,6 +793,7 @@ function MessageTimelineView(
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
@@ -0,0 +1,14 @@
[data-component="timeline-search-bar"] [data-component="text-input-v2"] {
background: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 1px var(--v2-border-border-base);
outline: none;
}
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover {
background: var(--v2-background-bg-base);
}
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within {
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
outline: none;
}
@@ -0,0 +1,72 @@
import { Icon } from "@opencode-ai/ui/icon"
import "@opencode-ai/ui/text-input.css"
import { Show } from "solid-js"
import type { TimelineSearchController } from "./search-controller"
import "./search-bar.css"
export function TimelineSearchBar(props: { controller: TimelineSearchController }) {
const c = props.controller
return (
<Show when={c.visible()}>
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
<div data-slot="text-input-v2-value">
<span data-slot="text-input-v2-leading-icon">
<Icon name="magnifying-glass" size="small" />
</span>
<input
ref={c.element.setInput}
data-slot="text-input-v2-input"
type="search"
value={c.query.value()}
placeholder={c.query.placeholder()}
aria-label={c.query.placeholder()}
onInput={(event) => c.query.setValue(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
c.query.close()
return
}
if (event.altKey || event.metaKey || event.ctrlKey) return
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
c.result.move(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" && !event.isComposing) {
event.preventDefault()
c.result.move(1)
return
}
if (event.key === "ArrowUp" && !event.isComposing) {
event.preventDefault()
c.result.move(-1)
return
}
}}
/>
</div>
<Show when={c.query.value()}>
<span
data-slot="timeline-search-count"
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
>
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
</span>
</Show>
<button
type="button"
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
aria-label={c.query.placeholder()}
onMouseDown={(event) => event.preventDefault()}
onClick={() => c.query.close()}
>
<Icon name="xmark-small" />
</button>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,275 @@
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
export type TimelineSearchMatch = {
messageID: string
role: "user" | "assistant"
revealID: string
partID: string
occurrence: number
text: string
}
const HIGHLIGHT_HIT = "timeline-search-hit"
const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
function supportsHighlights() {
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
}
function clearHighlights() {
if (!supportsHighlights()) return
CSS.highlights.delete(HIGHLIGHT_HIT)
CSS.highlights.delete(HIGHLIGHT_ACTIVE)
}
function collectRanges(
root: HTMLElement,
query: string,
activePartID: string | undefined,
activeOccurrence: number | undefined,
) {
const hits: Range[] = []
const active: Range[] = []
const lower = query.toLowerCase()
const bodies = root.querySelectorAll<HTMLElement>(TEXT_SELECTORS)
for (const body of bodies) {
const part = body.closest("[data-timeline-part-id]")
const partID = part?.getAttribute("data-timeline-part-id")
const isActivePart = activePartID !== undefined && partID === activePartID
let occurrenceInPart = 0
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
let node = walker.nextNode() as Text | null
while (node) {
const value = node.nodeValue ?? ""
const lowerValue = value.toLowerCase()
let from = 0
let at = lowerValue.indexOf(lower, from)
while (at !== -1) {
const range = document.createRange()
range.setStart(node, at)
range.setEnd(node, at + query.length)
if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range)
else hits.push(range)
occurrenceInPart += 1
from = at + query.length
at = lowerValue.indexOf(lower, from)
}
node = walker.nextNode() as Text | null
}
}
return { hits, active }
}
function applyHighlights(
root: HTMLElement,
query: string,
activePartID: string | undefined,
activeOccurrence: number | undefined,
) {
if (!supportsHighlights()) return
const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence)
CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits))
CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active))
}
export function createTimelineSearchController(input: {
sessionID: () => string | undefined
scrollRef: () => HTMLDivElement | undefined
revealMessage: (id: string, partID?: string) => void
pauseAutoScroll: () => void
}) {
const command = useCommand()
const language = useLanguage()
const data = useData()
const [state, setState] = createStore({ value: "", active: 0, visible: false })
const [focusTick, setFocusTick] = createSignal(0)
let inputEl: HTMLInputElement | undefined
const query = createMemo(() => state.value.trim().toLowerCase())
const matches = createMemo<TimelineSearchMatch[]>(() => {
const value = query()
if (!value) return []
const sessionID = input.sessionID()
if (!sessionID) return []
const messages = data.session.message.list(sessionID)
const result: TimelineSearchMatch[] = []
let revealID = ""
for (const message of messages) {
if (message.type === "user" || message.type === "shell") revealID = message.id
if (message.type !== "user" && message.type !== "assistant") continue
const visibleParts =
message.type === "user"
? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }]
: Timeline.contentEntries(message)
for (const textPart of visibleParts) {
if (textPart.content.type !== "text") continue
const text = textPart.content.text
if (!text) continue
const lower = text.toLowerCase()
let from = 0
let occurrence = 0
let at = lower.indexOf(value, from)
while (at !== -1) {
result.push({
messageID: message.id,
role: message.type,
revealID,
partID: textPart.id,
occurrence,
text,
})
occurrence += 1
from = at + value.length
at = lower.indexOf(value, from)
}
}
}
return result
})
const activeIndex = createMemo(() => {
const list = matches()
if (list.length === 0) return 0
if (state.active >= list.length) return 0
if (state.active < 0) return 0
return state.active
})
const activePartID = createMemo(() => matches()[activeIndex()]?.partID)
const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence)
createEffect(() => {
const root = input.scrollRef()
const q = query()
if (!root || !state.visible || !q) {
clearHighlights()
return
}
applyHighlights(root, q, activePartID(), activeOccurrence())
let frame: number | undefined
const scheduleApply = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
if (!state.visible) return
applyHighlights(root, query(), activePartID(), activeOccurrence())
})
}
const observer = new MutationObserver(scheduleApply)
observer.observe(root, { childList: true, subtree: true, characterData: true })
onCleanup(() => {
observer.disconnect()
if (frame !== undefined) cancelAnimationFrame(frame)
clearHighlights()
})
})
createEffect(
on(focusTick, () => {
if (!state.visible) return
requestAnimationFrame(() => {
inputEl?.focus()
inputEl?.select()
})
}),
)
command.register("session.search", () => [
{
id: "session.search",
title: language.t("session.search.placeholder"),
keybind: "mod+f",
hidden: true,
onSelect: () => open(),
},
])
const onOpenRequest = () => open()
document.addEventListener("opencode:timeline-search-open", onOpenRequest)
onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest))
function open() {
setState("visible", true)
setFocusTick((t) => t + 1)
}
function close() {
setState({ value: "", active: 0, visible: false })
inputEl?.blur()
}
function setValue(value: string) {
setState("value", value)
const list = matches()
const match = list[0]
if (!value.trim() || !match) {
setState("active", 0)
return
}
setState("active", 0)
input.pauseAutoScroll()
input.revealMessage(match.revealID, match.partID)
scrollToMatch(match)
}
function scrollToMatch(match: TimelineSearchMatch) {
let attempts = 0
const seek = () => {
if (!state.visible) return
const root = input.scrollRef()
if (!root) return
const { active } = collectRanges(root, query(), match.partID, match.occurrence)
if (active.length === 0) {
if (attempts++ < 12) requestAnimationFrame(seek)
return
}
const rect = active[0].getBoundingClientRect()
const rootRect = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2
root.scrollTo({ top: Math.max(0, top), behavior: "auto" })
}
requestAnimationFrame(seek)
}
function move(delta: number) {
const list = matches()
if (list.length === 0) return
const next = (activeIndex() + delta + list.length) % list.length
setState("active", next)
const match = list[next]
if (!match) return
input.pauseAutoScroll()
input.revealMessage(match.revealID, match.partID)
scrollToMatch(match)
}
return {
visible: () => state.visible,
query: {
value: () => state.value,
placeholder: () => language.t("session.search.placeholder"),
noResults: () => language.t("session.search.noResults"),
open,
close,
setValue,
},
result: {
activeIndex,
count: () => matches().length,
move,
},
element: {
setInput: (element: HTMLInputElement) => (inputEl = element),
},
}
}
export type TimelineSearchController = ReturnType<typeof createTimelineSearchController>
@@ -7,7 +7,6 @@ import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -28,7 +27,6 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const data = useData()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
@@ -99,13 +97,13 @@ export function SessionWorkspaceMenu(props: {
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
<Icon name="plus" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
@@ -113,7 +111,7 @@ export function SessionWorkspaceMenu(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
@@ -123,10 +121,6 @@ export function SessionWorkspaceMenu(props: {
</Menu.Sub>
</Show>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -69,7 +69,7 @@ type Input = {
row: TimelineRow.TimelineRow,
disclosure: Readonly<Record<string, boolean | undefined>>,
) => boolean
setRevealMessage?: (fn: (id: string) => void) => void
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
}
@@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
input.setRevealMessage?.((id) => {
const index = input.projection.messageRowIndex().get(id)
input.setRevealMessage?.((id, partID) => {
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
)
: -1
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
@@ -1,4 +1,4 @@
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
@@ -61,6 +61,22 @@ export const SettingsExperimental: Component = () => {
</Switch>
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
description={language.t("settings.general.row.showProjectIcon.description")}
>
<div data-action="settings-show-project-icon">
<Switch
checked={settings.general.showProjectIcon()}
onChange={settings.general.setShowProjectIcon}
hideLabel
>
{language.t("settings.general.row.showProjectIcon.title")}
</Switch>
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</div>
@@ -348,20 +348,6 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
description={language.t("settings.general.row.showProjectIcon.description")}
>
<div data-action="settings-show-project-icon">
<Switch
checked={settings.general.showProjectIcon()}
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
/>
</div>
</SettingsRow>
</Show>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
+101 -13
View File
@@ -1029,25 +1029,92 @@
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
padding: 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
padding: 0;
background-color: transparent;
box-shadow: none;
}
.settings-workspaces-row-motion {
display: grid;
grid-template-rows: minmax(0, 1fr);
opacity: 1;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-row-motion[data-removing="true"] {
grid-template-rows: minmax(0, 0fr);
opacity: 0;
}
.settings-workspaces-row {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: hidden;
transition:
padding-bottom 150ms ease-out,
margin-bottom 150ms ease-out,
border-color 120ms ease-out;
}
.settings-workspaces-row:not(:last-child) {
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
> .settings-workspaces-row {
padding-bottom: 0;
margin-bottom: 0;
border-color: transparent;
}
.settings-workspaces-empty-motion {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 0fr);
opacity: 0;
overflow: hidden;
pointer-events: none;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-empty-motion[data-visible="true"] {
grid-template-rows: minmax(0, 1fr);
opacity: 1;
pointer-events: auto;
}
.settings-workspaces-empty-motion > .settings-workspaces-empty {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.settings-workspaces-row-motion {
transition: none;
}
.settings-workspaces-row,
.settings-workspaces-empty-motion {
transition: none;
}
}
.settings-workspaces-row-header {
display: flex;
min-width: 0;
@@ -1088,7 +1155,7 @@
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
@@ -1098,6 +1165,11 @@
cursor: default;
}
.settings-workspaces-path-name {
color: var(--v2-text-text-base);
font-weight: 530;
}
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
@@ -1105,6 +1177,10 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-meta-project {
color: var(--v2-text-text-muted);
}
.settings-workspaces-active,
.settings-workspaces-more {
flex-shrink: 0;
@@ -1114,11 +1190,16 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-active {
font-size: 13px;
line-height: var(--line-height-compact);
}
.settings-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
@@ -1129,7 +1210,8 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
padding-block: 10px;
padding-inline: 12px 16px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
@@ -1149,22 +1231,28 @@
.settings-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
.settings-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
+21 -4
View File
@@ -1,4 +1,5 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -35,7 +36,7 @@ const sections = [
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
@@ -54,6 +55,7 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
onMount(() => {
@@ -146,7 +148,14 @@ export const SettingsScreen: Component = () => {
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem value={section.value} closeOnSelect>
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
@@ -172,7 +181,12 @@ export const SettingsScreen: Component = () => {
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger value={section.value}>
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
@@ -208,7 +222,10 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces activeDirectory={directory()} />
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -1,6 +1,8 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -46,7 +48,9 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -56,6 +60,12 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
deleting: [] as string[],
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
@@ -111,6 +121,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
placeholderData: (previous) => previous,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -123,14 +134,29 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
project,
})
const start = label.lastIndexOf(project)
if (start < 0) return label
return (
<>
{label.slice(0, start)}
<span class="settings-workspaces-meta-project">{project}</span>
{label.slice(start + project.length)}
</>
)
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
@@ -158,54 +184,64 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
return messages
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
description: result.active
? language.t("settings.workspaces.delete.blocked.active")
: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])
try {
const preflight = await inspect(workspace, context)
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
}
}
let inspectionID = 0
@@ -253,13 +289,30 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
const confirmDeleteWithoutSessions = () => {
if (store.transaction || workspacesWithoutSessions().length === 0) return
const context = captureDeleteContext()
const inventory = [...workspacesWithoutSessions()]
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
@@ -271,44 +324,45 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
@@ -321,78 +375,83 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
/>
<Menu.Portal>
<Menu.Content>
<Show when={workspacesWithoutSessions().length > 0}>
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
{language.t("settings.workspaces.deleteWithoutSessions")}
</Menu.Item>
<Menu.Separator />
</Show>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</div>
</div>
</div>
</Show>
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
<SettingsList>
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
<Show
when={!projectQuery.isPending && !projectQuery.isError}
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
>
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
<span>{language.t("settings.workspaces.empty.description")}</span>
</Show>
</div>
}
>
<SettingsList>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
</div>
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
{(workspace) => {
const linked = () => workspaceSessions(workspace())
const key = () => String(pathKey(workspace().directory))
const deleting = () => store.deleting.includes(key())
return (
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
<div class="settings-workspaces-row">
<div class="settings-workspaces-row-header">
<div class="settings-workspaces-copy">
<div class="settings-workspaces-main">
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-workspaces-path"
>
{workspace.directory}
</span>
</Tooltip>
<WorkspacePath directory={workspace().directory} />
</div>
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
</div>
<div class="settings-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
<Show
when={deleting()}
fallback={
<>
<Show when={lastActive(workspace())}>
{(value) => (
<Tooltip
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace().directory),
})}
disabled={!!store.transaction}
icon={<Icon name="outline-trash" size="small" />}
onClick={() => confirmDelete(workspace())}
/>
</>
}
>
<span class="settings-workspaces-active">{language.t("workspace.lifecycle.deleting")}</span>
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
@@ -401,7 +460,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
{(session) => (
<div class="settings-workspaces-session">
<span>{sessionLabel(session)}</span>
<Show when={sessionTime(session)}>
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
</Show>
</div>
@@ -410,18 +469,48 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
</div>
</Show>
</div>
)
}}
</For>
</SettingsList>
</Show>
</div>
)
}}
</Key>
</SettingsList>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
function WorkspacePath(props: { directory: string }) {
const [truncated, setTruncated] = createSignal(false)
const name = () => getFilename(props.directory)
return (
<Tooltip
value={props.directory}
placement="top-start"
disabled={!truncated()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
tabIndex={truncated() ? 0 : undefined}
dir="ltr"
aria-label={props.directory}
class="settings-workspaces-path"
>
<span>{props.directory.slice(0, -name().length)}</span>
<span class="settings-workspaces-path-name">{name()}</span>
</span>
</Tooltip>
)
}
function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
@@ -434,13 +523,12 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
title={props.title}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
<div class="flex flex-col gap-2">
<div>{props.confirmation}</div>
<div>{props.warning}</div>
</div>
}
/>
</DialogHeader>
@@ -449,7 +537,7 @@ function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDe
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
{language.t("settings.workspaces.delete.button")}
</Button>
</DialogFooter>
</Dialog>
@@ -472,7 +560,7 @@ function DialogDeleteWorkspace(props: {
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isPending) return []
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
@@ -487,18 +575,20 @@ function DialogDeleteWorkspace(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-11-regular text-v2-text-text-faint">
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
</span>
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
</div>
<div>{language.t("settings.workspaces.delete.warning")}</div>
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
</div>
}
/>
</DialogHeader>
@@ -506,12 +596,7 @@ function DialogDeleteWorkspace(props: {
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
<Button type="button" variant="danger" disabled={status.isPending || status.isError} onClick={remove}>
{language.t("workspace.delete.button")}
</Button>
</DialogFooter>
@@ -1,6 +1,6 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
+1 -1
View File
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
min={140}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>

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