Compare commits

..

73 Commits

Author SHA1 Message Date
Brendan Allan c215c42fe0 refactor(app): initialize feature visibility at runtime 2026-07-15 14:48:28 +00:00
Brendan Allan f8a5fdcc26 fix(app): default advanced features for new users 2026-07-15 12:42:52 +00:00
opencode-agent[bot] 04bdf7732b fix(app): show help button in release builds (#37098)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-15 19:17:38 +08:00
opencode-agent[bot] ab7dfb1edc feat(app): open new tabs with mod+n (#37079)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-15 10:18:34 +00:00
Brendan Allan 05c3e40a4e fix(app): tolerate null session archive times (#36999)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-15 03:02:49 +00:00
opencode-agent[bot] b6478dcebf fix(opencode): default Meta reasoning to xhigh (#36976)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-14 19:52:04 -05:00
Luke Parker 571e7b852f fix(app): hide drawer close button on Windows (#36952) 2026-07-15 08:23:06 +10:00
opencode 888dd26e1d sync release versions for v1.18.1 2026-07-14 21:37:51 +00:00
opencode-agent[bot] 279c8bdd7f fix(console): compress desktop promo video (#36945)
deploy / deploy (push) Has been cancelled
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-14 17:35:40 -04:00
opencode-agent[bot] 865e4d5c86 chore: generate 2026-07-14 21:09:01 +00:00
David Hill 64950cd358 feat(console): promote desktop tabs launch (#35972) 2026-07-14 21:07:23 +00:00
Luke Parker 36157938a8 fix(app): preserve themed diff backgrounds (#36931) 2026-07-15 07:00:22 +10:00
opencode-agent[bot] c0dea334fb fix(app): space model provider sections (#36935)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-15 06:59:34 +10:00
Luke Parker 9b49daf3a9 fix(app): update tabs drawer copy (#36937) 2026-07-15 06:57:38 +10:00
opencode d6f1b08ce8 sync release versions for v1.18.0 2026-07-14 20:29:46 +00:00
opencode-agent[bot] 75cf4cc8a8 chore: generate 2026-07-14 19:58:26 +00:00
Brendan Allan 4a181c3574 desktop v2 migration finalising (#36912) 2026-07-15 03:56:47 +08:00
opencode-agent[bot] bfddf05782 fix(app): use v2 base background for file views (#36836)
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
2026-07-14 17:27:54 +00:00
opencode-agent[bot] cdee625a13 fix: preserve team login casing (#35981)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-14 17:10:11 +00:00
Luke Parker 5f7091ab4e fix(app): wait for connected project picker anchor (#36879) 2026-07-15 01:36:45 +10:00
Luke Parker e7e7a97648 fix(app): retain permission state per server (#36873) 2026-07-14 15:26:58 +00:00
Luke Parker f58b8cb67b fix(app): avoid project picker positioning crash (#36864) 2026-07-15 01:05:02 +10:00
opencode-agent[bot] 775f687ca9 chore: generate 2026-07-14 14:52:00 +00:00
usrnk1 265a93927d feat(desktop): add layout transition switch (#36667)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-14 14:50:20 +00:00
Aarav Sareen 3a938bb6d8 feat(app): update new session page logo (#36700) 2026-07-14 23:29:32 +10:00
opencode-agent[bot] 6f9a4e70da chore: generate 2026-07-14 13:14:16 +00:00
usrnk1 dedb4133dc feat(desktop): improve file comments in session timeline (#36845)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-14 23:12:46 +10:00
Luke Parker 21f8184c58 fix(app): suppress review sidebar hydration motion (#36847) 2026-07-14 22:50:16 +10:00
Aarav Sareen 59fe350972 fix(app): terminal tab rename focus (#36695) 2026-07-14 22:39:56 +10:00
Aarav Sareen 5bdfcaac3c fix(app): settings/help button misalignment in projects sidebar (#36662) 2026-07-14 22:22:52 +10:00
Aarav Sareen 323d687173 feat(app): align custom agent selector with v2 (#36832) 2026-07-14 22:10:42 +10:00
Luke Parker cdca203be2 fix(app): enable remote session auto-accept (#36777) 2026-07-14 21:59:02 +10:00
opencode-agent[bot] 77a31d2b6e chore: generate 2026-07-14 11:51:22 +00:00
Luke Parker a1ed99278a fix(app): prevent terminal mount from stealing focus (#36576) 2026-07-14 21:49:57 +10:00
Luke Parker 729bd438e4 fix(app): preload more timeline messages (#36172) 2026-07-14 21:48:38 +10:00
Luke Parker 7304bb2751 fix(app): resync timeline after route reconnect (#36643) 2026-07-14 21:47:33 +10:00
Adam 456521f689 fix(data): align comparison suggestions 2026-07-14 05:44:45 -05:00
Adam ee69079bb4 fix(data): proxy comparison sitemap 2026-07-14 05:30:13 -05:00
Adam de14bb2e0f fix(data): improve comparison metadata 2026-07-14 05:20:53 -05:00
Adam 0539d56fc4 fix(data): polish comparison details 2026-07-14 05:09:15 -05:00
Adam 729adde022 fix(data): show all comparison models 2026-07-14 04:58:27 -05:00
opencode-agent[bot] b7111d5c62 chore: generate 2026-07-14 09:45:09 +00:00
Adam 1669713fbc fix(data): restore breadcrumb styling 2026-07-14 04:43:23 -05:00
opencode-agent[bot] cb8be9ba12 chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-14 03:38:03 +00:00
David Hill 15046658ba feat(app): update tabs intro content (#36701)
Co-authored-by: Jay <air@live.ca>
2026-07-14 11:36:22 +08:00
opencode-agent[bot] 7255ce9a57 chore: update nix node_modules hashes 2026-07-14 00:23:00 +00:00
opencode-agent[bot] 2b9cf5d388 chore: generate 2026-07-14 00:05:56 +00:00
Luke Parker a625d35f7b fix(app): preserve composer caret after requests (#36503) 2026-07-14 10:04:11 +10:00
Luke Parker 449c64928b fix(app): 78x faster Home cold loading (#36214) 2026-07-14 10:03:51 +10:00
Luke Parker 35c88c3fc8 fix(app): preserve timeline bottom anchoring (#36160) 2026-07-14 10:03:18 +10:00
Luke Parker b5e09024d5 fix(app): clarify status indicator severity (#36031) 2026-07-14 10:02:46 +10:00
Luke Parker 9a51765bd2 fix(ui): preserve code spans adjacent to tildes (#35835) 2026-07-14 10:02:30 +10:00
opencode bb31c9b92c sync release versions for v1.17.20 2026-07-13 21:09:53 +00:00
Aiden Cline 5117679148 fix(opencode): remove obsolete Luna Responses Lite workaround (#36750) 2026-07-13 15:29:41 -05:00
opencode-agent[bot] 0d5798c0bf chore: update nix node_modules hashes 2026-07-13 19:05:41 +00:00
Vladimir Glafirov 808677a612 chore: bump gitlab-ai-provider to 6.11.1 (#36722) 2026-07-13 13:47:42 -05:00
opencode-agent[bot] fe41e9d482 chore: update nix node_modules hashes 2026-07-13 18:16:33 +00:00
opencode-agent[bot] 0768c4655e chore: generate 2026-07-13 18:01:24 +00:00
Adam 1586a26594 fix(data): selects 2026-07-13 12:59:37 -05:00
Adam f026c1422c fix(data): select component ux 2026-07-13 12:59:37 -05:00
opencode-agent[bot] b7fdb595df chore: update nix node_modules hashes 2026-07-13 17:27:30 +00:00
Aiden Cline 049ee1ce5e chore(provider): bump Azure AI SDK for GPT-5.6 (#36704) 2026-07-13 12:10:27 -05:00
opencode e71fbb6d48 sync release versions for v1.17.19
deploy / deploy (push) Has been cancelled
2026-07-13 16:34:54 +00:00
opencode-agent[bot] c9976d69c0 chore: update nix node_modules hashes 2026-07-13 16:22:51 +00:00
opencode-agent[bot] 27734409e4 chore: generate 2026-07-13 16:06:35 +00:00
Aiden Cline e434ce01d3 fix(provider): support OpenAI pro reasoning mode (#36694) 2026-07-13 11:04:46 -05:00
opencode-agent[bot] c2f93ae2ad chore: generate 2026-07-13 15:55:09 +00:00
Adam 92cc8f553a fix(stats): correct breadcrumb behavior 2026-07-13 10:53:11 -05:00
Mark 49d997aec3 fix(xai): default store to false for Responses (#36629)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-13 10:37:12 -05:00
Aiden Cline 67aa9cee23 fix(opencode): support Luna Responses Lite over OAuth (#36685)
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
2026-07-13 10:15:06 -05:00
opencode-agent[bot] 7ec3d67829 chore: update nix node_modules hashes 2026-07-13 14:50:37 +00:00
Vladimir Glafirov 5401ebaede chore: bump gitlab-ai-provider to 6.11.0 (#36668) 2026-07-13 09:31:20 -05:00
Aiden Cline 99668cfdce fix(provider): tweak reasoning option driven max budget variants (#36626) 2026-07-13 09:18:32 -05:00
176 changed files with 4453 additions and 1391 deletions
+1 -1
View File
@@ -18,4 +18,4 @@ simonklee
Slickstef11
usrnk1
vimtor
starptech
StarpTech
+83 -49
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -95,7 +95,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.18.1",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -143,7 +143,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -157,7 +157,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -193,7 +193,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -220,7 +220,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -242,7 +242,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -266,7 +266,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -286,7 +286,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.18",
"version": "1.18.1",
"bin": {
"opencode": "./bin/opencode",
},
@@ -294,7 +294,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -303,7 +303,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -338,7 +338,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -380,7 +380,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -434,7 +434,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -448,7 +448,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"effect": "catalog:",
},
@@ -460,7 +460,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +539,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +558,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.18",
"version": "1.18.1",
"bin": {
"opencode": "./bin/opencode",
},
@@ -569,7 +569,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -578,7 +578,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -631,7 +631,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -689,7 +689,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -765,7 +765,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -780,7 +780,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -795,7 +795,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -839,7 +839,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -852,9 +852,10 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -885,7 +886,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -904,7 +905,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -945,7 +946,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -972,7 +973,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1023,7 +1024,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.18",
"version": "1.18.1",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1066,6 +1067,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1073,10 +1075,8 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
"overrides": {
@@ -1112,7 +1112,7 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -1132,7 +1132,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -1174,7 +1174,7 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
@@ -1184,7 +1184,7 @@
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
@@ -2780,9 +2780,9 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
@@ -3850,7 +3850,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4312,7 +4312,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -5654,7 +5654,11 @@
"@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/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=="],
"@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=="],
"@ai-sdk/cerebras/@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=="],
@@ -5666,9 +5670,9 @@
"@ai-sdk/deepinfra/@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=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/deepseek/@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/deepseek/@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/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6020,7 +6024,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@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=="],
"@opencode-ai/core/@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=="],
@@ -6046,6 +6050,8 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6162,6 +6168,10 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -6388,7 +6398,7 @@
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@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=="],
"opencode/@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=="],
@@ -6896,6 +6906,10 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@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=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
@@ -6966,6 +6980,14 @@
"ai-gateway-provider/@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-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"ai-gateway-provider/@ai-sdk/azure/@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=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@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-gateway-provider/@ai-sdk/xai/@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=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7118,6 +7140,10 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"opencode/@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=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7366,6 +7392,8 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7382,6 +7410,10 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -7452,6 +7484,8 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
}
}
+4 -5
View File
@@ -43,7 +43,7 @@
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -152,10 +152,9 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
@@ -136,6 +136,9 @@ export async function setupTimeline(
},
}),
)
if (settings.newLayoutDesigns === false) {
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
}
}, input.settings ?? {})
if (input.locale) {
await page.addInitScript((locale) => {
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -0,0 +1,270 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
const sessionA = session("ses_server_a", directoryA, "Server A session")
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
permissionRequests.length = 0
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(input).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
})
await mockServers(page, permissionRequests, permissionResponses)
await configureServers(page, [
{ type: "session", server: serverA, sessionId: sessionA.id },
{ type: "session", server: serverB, sessionId: sessionB.id },
])
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a",
type: "permission.asked",
properties: {
id: "permission-background-a",
sessionID: sessionA.id,
permission: "bash",
patterns: ["git status"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
])
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a-child",
type: "permission.asked",
properties: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
permission: "bash",
patterns: ["git diff"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
{
origin: serverA,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { response: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
sessionID: string
permissionID: string
body: unknown
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
},
{ serverB, tabs },
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
sessionID: response[1]!,
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") {
permissionRequests.push(url.toString())
return json(route, [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: directory,
config: directory,
worktree: directory,
directory,
home: directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
function provider(id: string) {
const name = id === "server-b" ? "Server B" : "Server A"
return {
all: [
{
id,
name: `${name} Provider`,
models: {
[id]: {
id,
name: `${name} Model`,
family: id,
release_date: "2026-01-01",
limit: { context: 200_000 },
},
},
},
],
connected: [id],
default: { providerID: id, modelID: id },
}
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
const transport = await installSseTransport(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
const cursor = draft.length - 4
await expect
.poll(() =>
editor.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(element)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}),
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "question.asked",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
})
async function mockServer(
page: Page,
requests: {
@@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const assistants = Array.from({ length: 14 }, (_, index) =>
const initialPageSize = 20
const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < 13,
completed: index < initialPageSize,
}),
)
const messages = [userMessage(), ...assistants]
@@ -46,7 +48,7 @@ const scenarios = [
test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) {
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
const requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = []
@@ -101,36 +103,51 @@ for (const scenario of scenarios) {
}
},
})
await page.addInitScript(
({ userPartID, lastPartID }) => {
const state = { armed: false, hidden: false, samples: 0, stop: false }
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect &&
!!view &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > view.top &&
rect.top < view.bottom
)
}
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
state.samples++
await page.addInitScript(() => {
const visibleParts = () => {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
if (!viewport || !view) return []
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((part) => {
const rect = part.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
}
const state = {
armed: false,
hidden: false,
visibleParts: [] as string[],
samples: 0,
stop: false,
arm() {
state.visibleParts = visibleParts()
state.armed = true
},
}
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
)
}
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
state.hidden = true
state.samples++
}
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ userPartID, lastPartID },
)
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}
requestAnimationFrame(() => setTimeout(sample, 0))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
@@ -143,23 +160,28 @@ for (const scenario of scenarios) {
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-2)!.info.id}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
await page.evaluate(() => {
;(
window as Window & {
__historyRootProbe?: { armed: boolean }
__historyRootProbe?: { arm(): void }
}
).__historyRootProbe!.armed = true
).__historyRootProbe!.arm()
})
await waitForProbeSamples(page, 0)
expect(await historyRootHidden(page)).toBe(false)
expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
expect(pages).toEqual([
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info)
@@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) {
)
}
function historyRootHidden(page: Page) {
function visibleContentHidden(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
)
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
test.use({ viewport: { width: 1440, height: 900 } })
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
await expect(composer).toBeFocused()
await expect(composer).toHaveText("a")
})
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
})
})
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await seedCachedTerminal(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
expect(created.count).toBe(0)
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(300)
await expect(composer).toBeFocused()
})
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/ghostty-web/, async (route) => {
ghostty.resolve()
await release.promise
await route.continue()
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await ghostty.promise
await composer.click()
await expect(composer).toBeFocused()
release.resolve()
await expect(terminal.locator("textarea")).toHaveCount(1)
await page.waitForTimeout(50)
await expect(composer).toBeFocused()
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/pty", (route) => {
created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
})
})
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await composer.click()
await expect(composer).toBeFocused()
await page.getByRole("button", { name: "New terminal" }).click()
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
localStorage.setItem(
terminalKey,
JSON.stringify({
active: ptyID,
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
}),
)
},
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Timeline Suspense Reproduction</title>
<style>
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background: #171717;
color: #f5f5f5;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
#root {
padding: 24px;
}
</style>
</head>
<body>
<main id="root"></main>
<script type="module" src="/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,315 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
const parameters = new URLSearchParams(location.search)
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
type MutationEvent = {
kind: "removed" | "added"
callbackTime: number
callbackFrame: number
routeConnectedInCallback: boolean
nativeOffsetInCallback: number
}
type Snapshot = {
mode: {
resource: "baseline" | "guard"
reconnect: "baseline" | "candidate"
}
operation: {
sequence: number
phase: string
time: number
frame: number
}
resourceState: string
routeConnected: boolean
viewportConnected: boolean
viewportOwnedByRoute: boolean
sameRoute: boolean
sameViewport: boolean
sameSurface: boolean
sameMountedRows: boolean
nativeOffset: number
coreOffset: number
rangeStart: number
rangeEnd: number
indexes: number[]
domIndexes: number[]
logicalSurfaceHeight: number
renderedSurfaceHeight: number
viewportClientHeight: number
viewportScrollHeight: number
visibleRows: number
minimumRowTop: number
domScrollEvents: number
lastScrollTrusted: boolean
coreOffsetCallbackCalls: number
offsetCallbackSources: "observer"[]
rectObserverCallbacks: number
ignoredDetachedZeroRects: number
syntheticScrollDispatches: number
mutationEvents: MutationEvent[]
}
declare global {
interface Window {
timelineSuspense: {
prepare: () => Promise<Snapshot>
trigger: () => void
resolve: () => void
frames: (count?: number) => Promise<void>
snapshot: () => Snapshot
}
}
}
function App() {
const [refresh, setRefresh] = createSignal(false)
let resolveResource: (() => void) | undefined
const [resource] = createResource(
refresh,
(version) =>
new Promise<string>((resolve) => {
resolveResource = () => resolve(`settled-${version}`)
}),
{ initialValue: "settled" },
)
function Route() {
let route: HTMLElement | undefined
let viewport: HTMLDivElement | undefined
let surface: HTMLDivElement | undefined
let initialRoute: HTMLElement | undefined
let initialViewport: HTMLDivElement | undefined
let initialSurface: HTMLDivElement | undefined
let initialRows: HTMLElement[] = []
let phase = "mounting"
let browserFrame = 0
let snapshotSequence = 0
let domScrollEvents = 0
let lastScrollTrusted = false
let coreOffsetCallbackCalls = 0
let rectObserverCallbacks = 0
let ignoredDetachedZeroRects = 0
const offsetCallbackSources: "observer"[] = []
const mutationEvents: MutationEvent[] = []
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rowCount,
getScrollElement: () => viewport ?? null,
estimateSize: () => rowHeight,
initialRect: { width: 900, height: 600 },
overscan: 2,
observeElementRect: (instance, callback) =>
observeElementRect(instance, (rect) => {
rectObserverCallbacks++
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
if (!instance.scrollElement?.isConnected && rect.height === 0) {
ignoredDetachedZeroRects++
return
}
callback(rect)
}),
observeElementOffset: (instance, callback) => {
const deliver = (offset: number, isScrolling: boolean) => {
coreOffsetCallbackCalls++
offsetCallbackSources.push("observer")
callback(offset, isScrolling)
}
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
return observeElementOffset(instance, deliver)
},
})
const frames = async (count = 2) => {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
const snapshot = (): Snapshot => {
const rows = mountedRows()
const view = viewport?.getBoundingClientRect()
const visibleRows =
viewport?.isConnected && view
? rows.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
}).length
: 0
return {
mode: { resource: resourceMode, reconnect: reconnectMode },
operation: {
sequence: ++snapshotSequence,
phase,
time: performance.now(),
frame: browserFrame,
},
resourceState: resource.state,
routeConnected: route?.isConnected ?? false,
viewportConnected: viewport?.isConnected ?? false,
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
sameRoute: route === initialRoute,
sameViewport: viewport === initialViewport,
sameSurface: surface === initialSurface,
sameMountedRows:
initialRows.length > 0 &&
initialRows.length === rows.length &&
initialRows.every((row, index) => row === rows[index]),
nativeOffset: viewport?.scrollTop ?? -1,
coreOffset: virtualizer.scrollOffset ?? -1,
rangeStart: virtualizer.range?.startIndex ?? -1,
rangeEnd: virtualizer.range?.endIndex ?? -1,
indexes: virtualizer.getVirtualItems().map((item) => item.index),
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
viewportClientHeight: viewport?.clientHeight ?? -1,
viewportScrollHeight: viewport?.scrollHeight ?? -1,
visibleRows,
minimumRowTop:
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
domScrollEvents,
lastScrollTrusted,
coreOffsetCallbackCalls,
offsetCallbackSources: [...offsetCallbackSources],
rectObserverCallbacks,
ignoredDetachedZeroRects,
syntheticScrollDispatches: 0,
mutationEvents: mutationEvents.map((event) => ({ ...event })),
}
}
onMount(() => {
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
const routeRoot = route.parentElement
if (!routeRoot) throw new Error("Timeline route root did not mount")
initialRoute = route
initialViewport = viewport
initialSurface = surface
viewport.addEventListener("scroll", (event) => {
domScrollEvents++
lastScrollTrusted = event.isTrusted
})
const countFrames = () => {
browserFrame++
requestAnimationFrame(countFrames)
}
requestAnimationFrame(countFrames)
new MutationObserver((records) => {
const callbackTime = performance.now()
records.forEach((record) => {
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "detached"
mutationEvents.push({
kind: "removed",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
if (node !== route) return
phase = "reinserted"
mutationEvents.push({
kind: "added",
callbackTime,
callbackFrame: browserFrame,
routeConnectedInCallback: route.isConnected,
nativeOffsetInCallback: viewport.scrollTop,
})
})
})
}).observe(routeRoot, { childList: true })
window.timelineSuspense = {
prepare: async () => {
phase = "preparing"
await frames(2)
viewport.scrollTop = viewport.scrollHeight
await frames(3)
await new Promise((resolve) => setTimeout(resolve, 200))
await frames(2)
initialRows = mountedRows()
phase = "prepared"
return snapshot()
},
trigger: () => {
phase = "triggering"
setRefresh(true)
},
resolve: () => {
if (!resolveResource) throw new Error("Resource is not pending")
phase = "resolving"
resolveResource()
},
frames,
snapshot,
}
})
return (
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
<span aria-hidden="true" style={{ display: "none" }}>
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
</span>
<div
ref={viewport}
data-viewport
style={{
height: "600px",
overflow: "auto",
"overflow-anchor": "none",
position: "relative",
background: "#202020",
outline: "1px solid #3f3f46",
}}
>
<div
ref={surface}
data-surface
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
>
<For each={virtualizer.getVirtualItems()}>
{(item) => (
<div
data-row-index={item.index}
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
padding: "10px 14px",
border: "0 solid #333",
"border-bottom-width": "1px",
}}
>
logical row {item.index}
</div>
)}
</For>
</div>
</div>
</section>
)
}
return (
<main>
<Suspense>
<Route />
</Suspense>
</main>
)
}
render(() => <App />, document.getElementById("root")!)
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
export default defineConfig({
testDir: ".",
testMatch: "timeline-suspense.repro.ts",
outputDir: "../../test-results/timeline-suspense",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "line",
timeout: 30_000,
expect: {
timeout: 10_000,
},
webServer: {
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
cwd: import.meta.dirname,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
},
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})
@@ -0,0 +1,179 @@
import { expect, test, type Page } from "@playwright/test"
test.beforeEach(async ({ page }) => {
page.on("pageerror", (error) => console.error(error))
await page.goto("/")
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
})
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
await page.goto("/?reconnect=candidate")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
const before = await prepare(page)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.sameRoute).toBe(true)
expect(pending.sameViewport).toBe(true)
expect(pending.sameSurface).toBe(true)
expect(pending.sameMountedRows).toBe(true)
await resolveSuspension(page)
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
await page.waitForTimeout(250)
await page.evaluate(() => window.timelineSuspense.frames(2))
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(0)
expect(after.rangeStart).toBeLessThan(10)
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
expect(after.syntheticScrollDispatches).toBe(0)
})
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
page,
}) => {
const before = await prepare(page)
const beforeRows = before.domIndexes
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
expect(before.logicalSurfaceHeight).toBe(80_000)
expect(before.renderedSurfaceHeight).toBe(80_000)
expect(before.viewportClientHeight).toBe(600)
expect(before.viewportScrollHeight).toBe(80_000)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
expect(before.visibleRows).toBeGreaterThan(0)
await triggerBaselineSuspension(page)
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.resourceState).toBe("refreshing")
expect(pending.routeConnected).toBe(false)
expect(pending.viewportConnected).toBe(false)
expect(pending.viewportOwnedByRoute).toBe(true)
expect(pending.nativeOffset).toBe(0)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.rangeStart).toBe(before.rangeStart)
expect(pending.rangeEnd).toBe(before.rangeEnd)
expect(pending.indexes).toEqual(before.indexes)
expect(pending.domIndexes).toEqual(beforeRows)
expect(pending.sameMountedRows).toBe(true)
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
expect(pending.mutationEvents).toHaveLength(1)
expect(pending.mutationEvents[0]).toMatchObject({
kind: "removed",
routeConnectedInCallback: false,
nativeOffsetInCallback: 0,
})
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
const after = await resolveSuspension(page)
expect(after.resourceState).toBe("ready")
expect(after.routeConnected).toBe(true)
expect(after.viewportConnected).toBe(true)
expect(after.viewportOwnedByRoute).toBe(true)
expect(after.sameRoute).toBe(true)
expect(after.sameViewport).toBe(true)
expect(after.sameSurface).toBe(true)
expect(after.sameMountedRows).toBe(true)
expect(after.nativeOffset).toBe(0)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.rangeStart).toBe(before.rangeStart)
expect(after.rangeEnd).toBe(before.rangeEnd)
expect(after.indexes).toEqual(before.indexes)
expect(after.domIndexes).toEqual(beforeRows)
expect(after.domScrollEvents).toBe(before.domScrollEvents)
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
expect(after.mutationEvents).toHaveLength(2)
expect(after.mutationEvents[1]).toMatchObject({
kind: "added",
routeConnectedInCallback: true,
nativeOffsetInCallback: 0,
})
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
expect(after.visibleRows).toBe(0)
expect(after.minimumRowTop).toBeGreaterThan(50_000)
expect(after.syntheticScrollDispatches).toBe(0)
await page.locator("[data-viewport]").hover()
await page.mouse.wheel(0, 80)
await expect
.poll(() =>
page.evaluate(() => {
const value = window.timelineSuspense.snapshot()
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
}),
)
.toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(2))
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
expect(recovered.lastScrollTrusted).toBe(true)
expect(recovered.rangeStart).toBeLessThan(10)
expect(recovered.visibleRows).toBeGreaterThan(0)
})
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
await page.goto("/?resource=guard")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
const before = await prepare(page)
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await page.evaluate(() => window.timelineSuspense.frames(3))
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
expect(pending.routeConnected).toBe(true)
expect(pending.mutationEvents).toEqual([])
expect(pending.nativeOffset).toBe(before.nativeOffset)
expect(pending.coreOffset).toBe(before.coreOffset)
expect(pending.visibleRows).toBeGreaterThan(0)
const after = await resolveSuspension(page)
expect(after.routeConnected).toBe(true)
expect(after.nativeOffset).toBe(before.nativeOffset)
expect(after.coreOffset).toBe(before.coreOffset)
expect(after.visibleRows).toBeGreaterThan(0)
})
async function prepare(page: Page) {
const before = await page.evaluate(() => window.timelineSuspense.prepare())
expect(before.routeConnected).toBe(true)
expect(before.viewportConnected).toBe(true)
expect(before.viewportOwnedByRoute).toBe(true)
expect(before.sameMountedRows).toBe(true)
expect(before.rangeStart).toBeGreaterThan(1_900)
expect(before.nativeOffset).toBe(before.coreOffset)
return before
}
async function triggerBaselineSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.trigger())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
await page.evaluate(() => window.timelineSuspense.frames(3))
}
async function resolveSuspension(page: Page) {
await page.evaluate(() => window.timelineSuspense.resolve())
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
await page.evaluate(() => window.timelineSuspense.frames(3))
return page.evaluate(() => window.timelineSuspense.snapshot())
}
function diagnostic(value: unknown) {
return JSON.stringify(value, null, 2)
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
export default defineConfig({
root: import.meta.dirname,
plugins: [solid()],
})
+3
View File
@@ -10,6 +10,9 @@
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
+29
View File
@@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/api/session")
return json(route, {
data: config.sessions.map((session) => v2Session(session, config.directory)),
cursor: {},
})
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
...(typeof session.path === "string" ? { subpath: session.path } : {}),
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
+1 -1
View File
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(input, init)
return originalFetch(request)
const id = ++nextConnectionID
const record = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.18.1",
"description": "",
"type": "module",
"exports": {
+17 -27
View File
@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<DraftServerScopedProviders directory={directory}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</DraftServerScopedProviders>
</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
@@ -309,24 +308,21 @@ function DesktopCommands() {
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
)
}
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
@@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
)
}
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
@@ -559,13 +547,15 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+45 -30
View File
@@ -1,11 +1,12 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import { Persist, persisted } from "@/utils/persist"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
@@ -31,8 +32,6 @@ const triggerClass =
const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const platform = usePlatform()
return (
@@ -54,24 +53,22 @@ export function HelpButton() {
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={!state.dismissed}>
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={() => setState("dismissed", true)}
onClick={settings.general.dismissTabsToast}
>
<svg
width="16"
@@ -88,7 +85,7 @@ export function TabsInfoPopup() {
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
setState("dismissed", true)
settings.general.dismissTabsToast()
setDrawerOpen(true)
}}
>
@@ -107,7 +104,7 @@ export function TabsInfoPopup() {
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
Organize your work and active sessions with tabs
</p>
</div>
</button>
@@ -116,27 +113,45 @@ export function TabsInfoPopup() {
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
June 16
July 14
</p>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
+30 -16
View File
@@ -633,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover()
setComposing(false)
}
@@ -2201,10 +2203,7 @@ type ComposerModelControlState = {
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<div>
<TooltipV2
placement="top"
gutter={4}
@@ -2215,17 +2214,32 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
</>
}
>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={ButtonV2}
data-action="prompt-agent"
variant="ghost-muted"
size="normal"
class="max-w-[175px] justify-start ![font-weight:440]"
style={props.state.style}
>
<span class="truncate capitalize leading-5">{props.state.current}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" size="small" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup value={props.state.current} onChange={props.state.onSelect}>
{props.state.options.map((value) => (
<MenuV2.RadioItem value={value} class="capitalize">
{value}
</MenuV2.RadioItem>
))}
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
</div>
)
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
return {
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
@@ -122,13 +125,14 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/permission", () => ({
usePermission: () => ({
mock.module("@/context/permission", () => {
const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory })
enabledAutoAccept.push({ server, sessionID, directory })
},
}),
}))
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
})
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
@@ -251,6 +255,8 @@ beforeEach(() => {
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps auto-accept bound to the submission server", async () => {
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
release()
await result
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
@@ -1,4 +1,13 @@
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import {
createEffect,
createSignal,
For,
onCleanup,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
@@ -184,9 +193,28 @@ export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
const ready = () => {
if (!element.isConnected) {
triggerFrame = requestAnimationFrame(ready)
return
}
triggerFrame = undefined
setTriggerReady(true)
}
ready()
}
onCleanup(() => {
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
})
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -257,13 +285,13 @@ export function PromptProjectSelector(props: {
return (
<DropdownMenu
open={props.controller.open()}
open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={contentRef}
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
const focus = () => {
if (store.editing) return
terminal.requestFocus(props.terminal.id)
terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
}
const edit = (e?: Event) => {
@@ -379,7 +379,6 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -371,7 +371,7 @@
transform: translateY(-50%);
}
.settings-v2-tab-body.settings-v2-models {
.settings-v2-models {
gap: 24px;
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
@@ -0,0 +1,19 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
+25 -43
View File
@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +20,14 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
return (
<Popover
@@ -47,13 +46,11 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
issue: issue(),
})}`}
/>
</div>
}
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
issue: issue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
issue: boolean
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
}
+27 -2
View File
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
onAutoFocus?: () => void
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const [local, others] = splitProps(props, [
"pty",
"class",
"classList",
"autoFocus",
"onAutoFocus",
"onConnect",
"onConnectError",
])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
@@ -416,6 +425,7 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = fit
serializeAddon = serializer
const active = document.activeElement
t.open(container)
useTerminalUiBindings({
container,
@@ -425,7 +435,22 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick,
})
if (local.autoFocus !== false) focusTerminal()
if (local.autoFocus === true) {
focusTerminal()
local.onAutoFocus?.()
}
if (local.autoFocus !== true) {
const restoreFocus = () => {
const current = document.activeElement
if (current !== container && !container.contains(current)) return
t.blur()
t.textarea?.blur()
if (active instanceof HTMLElement && active.isConnected) active.focus()
}
restoreFocus()
const timer = setTimeout(restoreFocus, 0)
cleanups.push(() => clearTimeout(timer))
}
if (typeof document !== "undefined" && document.fonts) {
void document.fonts.ready.then(scheduleFit)
+1 -1
View File
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
id: "tab.new",
category: "tab",
title: language.t("command.session.new"),
keybind: "mod+t",
keybind: "mod+t,mod+n",
hidden: true,
onSelect: openNewTab,
},
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("keeps non-bootstrapping children passive until a real directory access", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
const bootstraps: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
const queries = querySingles.slice(offset)
expect(queries).toHaveLength(6)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(false)
expect(store.path.directory).toBe("/project")
expect(store.provider_ready).toBe(false)
expect(store.lsp_ready).toBe(false)
expect(bootstraps).toEqual([])
manager.child("/project")
expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"])
manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true)
} finally {
dispose()
}
})
})
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return instanceQueriesEnabled() && !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -250,6 +261,7 @@ export function createChildStoreManager(input: {
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -290,6 +302,7 @@ export function createChildStoreManager(input: {
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -301,6 +314,7 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap) activate(key)
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
@@ -314,6 +328,16 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
// Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered,
// updated-time v2.session.list, remove any Home-only passive child creation.
function activate(key: DirectoryKey) {
if (activeDirectories.has(key)) return
activeDirectories.add(key)
activationToggles.get(key)?.(true)
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
@@ -360,6 +384,7 @@ export function createChildStoreManager(input: {
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
runEviction,
@@ -243,6 +243,22 @@ describe("applyDirectoryEvent", () => {
expect(store.session_status.ses_1).toBeUndefined()
})
test("ignores an archived session absent from a passive directory store", () => {
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session).toEqual([])
expect(store.sessionTotal).toBe(0)
})
test("cleans session caches when deleted and decrements only root totals", () => {
const cases = [
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (!result.found) break
if (input.store.session[result.index]!.time.archived === info.time.archived) break
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
homeSessionIndexSessions,
homeSessionIndexRefresh,
parseHomeSessionIndex,
retainHomeSessions,
} from "./home-session-index"
const session = (input: {
id: string
directory?: string
parentID?: string
archived?: number
updated?: number
}) => ({
id: input.id,
parentID: input.parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
title: input.id,
location: { directory: input.directory ?? "/project" },
})
describe("Home V2 session index", () => {
test("loads the Home index with one global V2 request", async () => {
const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
return { data: { data: [session({ id: "root" })], cursor: {} } }
})
expect(result.sessions).toHaveLength(1)
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
})
test("loads subsequent pages until the session index is complete", async () => {
const calls: unknown[] = []
const controller = new AbortController()
const result = await loadHomeSessionIndex(
async (input, options) => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" },
},
}
}
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
},
0,
controller.signal,
)
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls).toEqual([
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
{
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
signal: controller.signal,
},
])
})
test("maps visible roots to Home session summaries", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionV2Info
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
session({ id: "child", parentID: "root", updated: 40 }),
session({ id: "archived", archived: 50, updated: 50 }),
])
expect(result).toEqual([
expect.objectContaining({
id: "root",
slug: "root",
version: "",
directory: "/project",
projectID: "project",
title: "root",
time: { created: 1, updated: 30 },
}),
expect.objectContaining({
id: "active-null",
time: { created: 1, updated: 20, archived: null },
}),
])
})
test("preserves the per-directory Home retention limit", () => {
const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
directory: index % 2 === 0 ? "/one" : "/two",
}))
const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
})
test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created",
properties: { sessionID: created.id, info: created },
})
expect(
applyHomeSessionEvent(afterCreate, {
type: "session.deleted",
properties: { sessionID: initial[0]!.id, info: initial[0]! },
}),
).toEqual([created])
})
test("applies only events newer than the index baseline", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })])
const stale = { ...initial[0], title: "stale" }
const current = { ...initial[0], title: "current" }
const first = appendHomeSessionEvent(undefined, {
type: "session.updated",
properties: { sessionID: stale.id, info: stale },
})
const events = appendHomeSessionEvent(first, {
type: "session.updated",
properties: { sessionID: current.id, info: current },
})
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
})
test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})
@@ -0,0 +1,174 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info: Session }
}
export type HomeSessionEvents = {
sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }>
}
export type HomeSessionIndex = {
sessions: Session[]
eventSequence: number
}
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<HomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
const response = await list(
{
limit: HOME_V2_SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
{ signal },
)
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
const sequence = (current?.sequence ?? 0) + 1
return {
sequence,
entries: [...(current?.entries ?? []), { sequence, event }],
}
}
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
return {
sequence: current?.sequence ?? sequence,
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
}
}
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
if (!index) return []
return (events?.entries ?? [])
.filter((entry) => entry.sequence > index.eventSequence)
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
}
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected }
return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
}
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
return {
indexKey,
eventsKey,
eventSequence() {
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
},
complete(sequence: number) {
// Keep events received after the fetch began so its response cannot overwrite them.
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
queryClient.setQueryData(eventsKey, next)
return
}
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
if (index) {
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
sessions: homeSessionIndexSessions(index, next),
eventSequence: next.sequence,
})
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
if (!result.refetch) return
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
},
}
}
// TODO(v2): This deliberately dumb full-table scan is necessary because the
// current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [toLegacySummary(item)]
})
}
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
}
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
if (index === -1) return [...sessions, info]
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { resolveAgent } from "./local-agent"
describe("resolveAgent", () => {
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
test("uses the requested available agent", () => {
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
})
test("defaults to build", () => {
expect(resolveAgent(agents)?.name).toBe("build")
expect(resolveAgent(agents, "missing")?.name).toBe("build")
})
test("uses the first agent when build is unavailable", () => {
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
})
})
+3
View File
@@ -0,0 +1,3 @@
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+6 -4
View File
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -62,6 +64,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
@@ -88,7 +91,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
variant?: string | null
}
}>({
current: list()[0]?.name,
current: resolveAgent(list())?.name,
draft: undefined,
last: undefined,
})
@@ -107,9 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
return resolveAgent(list(), name)
}
createEffect(() => {
@@ -181,6 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = {
list,
current() {
if (!settings.visibility.customAgents()) return pickAgent("build")
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
test("session-level override takes precedence over directory-level", () => {
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
describe("isDirectoryAutoAccepting", () => {
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
return autoAccept[key] ?? autoAccept[sessionID]
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
permission: { sessionID: string },
directory?: string,
) {
const value = sessionLineage(session, permission.sessionID)
const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
return value ?? false
}
+404 -214
View File
@@ -1,17 +1,24 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
import { useParams } from "@solidjs/router"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond"
type PermissionRespondFn = (input: {
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
init: () => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const owner = getOwner()
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
const activeDraft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const activeServer = createMemo(() => {
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
return activeDraft()?.server ?? server.key
})
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Permission server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
return existing.state
}
if (existing) {
existing.dispose()
states.delete(ctx.sdk.scope)
}
const root = createRoot(
(dispose) => ({
key,
dispose,
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
}),
owner ?? undefined,
)
states.set(ctx.sdk.scope, root)
return root.state
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const list = global.servers.list()
const keys = new Set(list.map(ServerConnection.key))
states.forEach((value, scope) => {
if (keys.has(value.key)) return
value.dispose()
states.delete(scope)
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
if (replacement) ensure(ServerConnection.key(replacement))
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
let lastSelected: PermissionState | undefined
const selected = () => {
const key = activeServer()
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
lastSelected = ensure(key)
}
if (lastSelected) return lastSelected
return ensure(server.key)
}
const activeDirectory = createMemo(() => {
const directory = decode64(params.dir)
if (directory) return directory
const draft = activeDraft()
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.directory
})
createEffect(() => {
const directory = activeDirectory()
if (!directory) return
selected().enableConfiguredDirectory(directory)
})
const permissionsEnabled = createMemo(() => {
const directory = props.directory?.() ?? decode64(params.dir)
const directory = activeDirectory()
if (!directory) return false
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
return selected().permissionsEnabled(directory)
})
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (input) => {
serverSDK()
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!shouldAutoRespond(perm, e.name)) return
respondOnce(perm, e.name)
})
onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
return {
ready,
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
return shouldAutoRespond(permission, directory)
ready: () => selected().ready(),
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
currentServerState: () => selected().api,
respond(input: Parameters<PermissionRespondFn>[0]) {
selected().respond(input)
},
autoResponds(permission: PermissionRequest, directory?: string) {
return selected().autoResponds(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
return selected().isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
return selected().isAutoAcceptingDirectory(directory)
},
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
selected().toggleAutoAccept(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
selected().toggleAutoAcceptDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
selected().enableAutoAccept(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID, directory)
selected().disableAutoAccept(sessionID, directory)
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
return selected().isPermissionAllowAll(directory)
},
}
},
})
type PermissionState = ReturnType<typeof createServerPermissionState>
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(directory?: string) {
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
if (!directory) return info
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID]
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
if (override !== undefined) return override
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
if (meta.disposed || !lineage) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.event.listen((event) => {
if (ready()) {
handlePermission(event)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(
perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}
@@ -174,7 +174,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -194,7 +194,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
+1 -1
View File
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const initialMessagePageSize = 20
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
+13 -1
View File
@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
let bootedAt = 0
let bootingRoot = false
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event)
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
}
homeSessions.refresh(event.type)
if (directory === "global") {
applyGlobalEvent({
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
directory,
store,
setStore,
push: queue.push,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+47
View File
@@ -1,12 +1,30 @@
import { describe, expect, test } from "bun:test"
import {
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
newLayoutDesignsDefault,
nextSunsetCheckDelay,
resolveNewLayoutDesigns,
shouldDisplayTabsToast,
shouldEnableNewLayout,
shouldEnableFeatureVisibility,
} from "./settings"
describe("feature visibility", () => {
test("enables features for profiles with persisted settings", () => {
expect(shouldEnableFeatureVisibility("{}", undefined)).toBe(true)
})
test("enables features for profiles with a recorded app version", () => {
expect(shouldEnableFeatureVisibility(null, "1.18.1")).toBe(true)
})
test("keeps features hidden for new profiles", () => {
expect(shouldEnableFeatureVisibility(null, undefined)).toBe(false)
})
})
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
@@ -37,4 +55,33 @@ describe("layout transition", () => {
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
})
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
})
test("enables the new layout when no previous version was recorded", () => {
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
})
test("detects upgrades only when a previous version is older", () => {
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
})
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
})
test("does not enable the new layout without a qualifying upgrade", () => {
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
})
})
+135 -13
View File
@@ -1,7 +1,8 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
export interface NotificationSettings {
agent: boolean
@@ -35,7 +36,9 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
featureVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
}
appearance: {
fontSize: number
@@ -57,7 +60,56 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export const newLayoutDesignsDefault = true
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
export const oldInterfaceSunset = new Date(2026, 8, 14)
const newLayoutDesignsUpgradeCutoff = "1.17.19"
export function shouldEnableFeatureVisibility(
settings: string | Promise<string> | null,
previousVersion: string | undefined,
) {
return settings !== null || previousVersion !== undefined
}
function compareVersions(a: string, b: string) {
const parse = (version: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
if (!match) return
return match.slice(1).map(Number)
}
const left = parse(a)
const right = parse(b)
if (!left || !right) return
const index = left.findIndex((part, index) => part !== right[index])
return index === -1 ? 0 : left[index]! - right[index]!
}
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
if (!previous || !current) return false
const comparison = compareVersions(current, previous)
return comparison !== undefined && comparison > 0
}
export function shouldDisplayTabsToast(
previous: string | undefined,
current: string | undefined,
existingInstall: boolean,
) {
return isAppUpgrade(previous, current) || (!previous && existingInstall)
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
if (!previous) return currentComparison !== undefined && currentComparison > 0
if (!isAppUpgrade(previous, current)) return false
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
return (
previousComparison !== undefined &&
currentComparison !== undefined &&
previousComparison <= 0 &&
currentComparison > 0
)
}
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return {
@@ -175,7 +227,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const platform = usePlatform()
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
)
const [launchState, setLaunchState] = createStore({
classified: false,
migrationApplied: false,
previous: undefined as string | undefined,
})
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -185,18 +247,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
)
const sunset = oldInterfaceSunset
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
const layoutTransitionClassified = createMemo(
() => typeof store.general?.layoutTransitionEligible === "boolean",
)
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
const layoutUpgrade = createMemo(() =>
launchState.classified && !launchState.migrationApplied
? shouldEnableNewLayout(launchState.previous, platform.version)
: false,
)
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
legacyNewLayoutDesignsDefault,
)
}
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
@@ -204,7 +274,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
const initializeFeatureVisibility = (existing: boolean) => {
if (store.general?.featureVisibilityInitialized === true) return
batch(() => {
setStore("general", "showFileTree", existing)
setStore("general", "showSearch", existing)
setStore("general", "showStatus", existing)
setStore("general", "showCustomAgents", existing)
setStore("general", "featureVisibilityInitialized", true)
})
}
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -221,12 +301,46 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
})
}
createEffect(() => {
if (!launchReady() || launchState.classified) return
setLaunchState({
classified: true,
previous: launch.version,
})
if (!platform.version || launch.version === platform.version) return
setLaunch("version", platform.version)
})
createEffect(() => {
if (!ready() || !launchState.classified || launchState.migrationApplied) return
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
setStore("general", "newLayoutDesigns", true)
}
setLaunchState("migrationApplied", true)
})
createEffect(() => {
if (!ready() || !launchState.classified) return
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
if (!launchState.previous && !layoutTransitionClassified()) return
setStore(
"general",
"shouldDisplayTabsToast",
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
)
})
createEffect(() => {
if (!ready() || !oldInterfaceRetired()) return
if (store.general?.newLayoutDesigns === true) return
setStore("general", "newLayoutDesigns", true)
})
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform === "desktop") return
initializeFeatureVisibility(shouldEnableFeatureVisibility(settingsInit, launchState.previous))
})
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
@@ -314,7 +428,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
layoutTransitionClassified,
setOldLayoutEligible(eligible: boolean) {
@@ -322,17 +439,22 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
initializeFeatureVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true)
},
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
},
},
visibility: {
fileTree: visible(showFileTree),
search: visible(showSearch),
status: visible(showStatus),
customAgents: visible(showCustomAgents),
fileTree: showFileTree,
search: showSearch,
status: showStatus,
customAgents: showCustomAgents,
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
+3 -2
View File
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
const draftID = uuid()
const tab = { type: "draft" as const, draftID, ...draft }
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
void startTransition(() => {
await startTransition(() => {
setStore(
produce((tabs) => {
tabs.push(tab)
@@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
navigate(draftHref(draftID))
})
return tab
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => {
+68 -5
View File
@@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
all: [],
}),
)
const [ui, setUi] = createStore({
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
})
const focus = { request: 0 }
const requestFocus = (id?: string, pending = false) => {
focus.request += 1
setUi("focus", { request: focus.request, id, pending })
return focus.request
}
const focusRequested = (id?: string) => {
if (!id) return false
if (!ui.focus || ui.focus.pending) return false
return !ui.focus.id || ui.focus.id === id
}
const consumeFocus = (id: string) => {
if (!focusRequested(id)) return
setUi("focus", undefined)
}
const cancelFocus = (request?: number) => {
if (request !== undefined && ui.focus?.request !== request) return
setUi("focus", undefined)
}
if (typeof document !== "undefined") {
const cancelOnOutsideFocus = (event: FocusEvent) => {
if (!ui.focus) return
if (!(event.target instanceof Element)) return
if (event.target.closest("#terminal-panel")) return
cancelFocus()
}
document.addEventListener("focusin", cancelOnOutsideFocus)
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
}
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
@@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
setStore("all", [])
})
},
new() {
new(options?: { focus?: boolean }) {
const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)
setStore("active", id)
batch(() => {
setStore("all", store.all.length, newTerminal)
setStore("active", id)
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
setUi("focus", { request: focusRequest, id, pending: false })
}
})
})
.catch((error: unknown) => {
if (focusRequest !== undefined) cancelFocus(focusRequest)
console.error("Failed to create terminal", error)
})
},
@@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
open(id: string) {
setStore("active", id)
},
requestFocus(id?: string) {
requestFocus(id)
},
focusRequested(id?: string) {
return focusRequested(id)
},
consumeFocus(id: string) {
consumeFocus(id)
},
cancelFocus() {
cancelFocus()
},
next() {
const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return
@@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
new: (options?: { focus?: boolean }) => workspace().new(options),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id),
requestFocus: (id?: string) => workspace().requestFocus(id),
focusRequested: (id?: string) => workspace().focusRequested(id),
consumeFocus: (id: string) => workspace().consumeFocus(id),
cancelFocus: () => workspace().cancelFocus(),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
next: () => workspace().next(),
+61 -26
View File
@@ -35,12 +35,12 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import {
@@ -50,7 +50,6 @@ import {
getProjectAvatarSource,
homeProjectDirectories,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: Pick<ServerSync, "child">
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
return [
...new Map(
input
.projectDirectories()
.flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, input.projects(), input.projectByID())
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return {
session,
@@ -269,7 +275,7 @@ export function NewHome() {
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsDialog()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -286,6 +292,7 @@ export function NewHome() {
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const homeSessions = () => focusedSync().homeSessions
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
@@ -318,24 +325,47 @@ export function NewHome() {
}
return language.t("home.sessions.search.placeholder")
})
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
),
queryKey: homeSessions().indexKey,
enabled: !!focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
return null
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync: focusedSync(),
sessions: indexedSessions,
projectDirectories,
projects,
projectByID,
@@ -363,8 +393,7 @@ export function NewHome() {
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
void directory.session
void ctx.sync.session
.sync(record.session.id)
.then(() => {
return Promise.all(
@@ -484,7 +513,13 @@ export function NewHome() {
}
function openSession(session: Session, options?: OpenSessionOptions) {
const project = projectForSession(session, projects(), projectByID())
const directoryKey = pathKey(session.directory)
const project =
projects().find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects(), projectByID())
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
@@ -795,7 +830,7 @@ function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-2
View File
@@ -6,13 +6,11 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true))
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
const global = useGlobal()
const notification = useNotification()
const permission = usePermission()
const permissionState = createMemo(() => permission.ensureServerState(server()))
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => {
const conn = connection()
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
return !permissionState().autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
+2 -1
View File
@@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
@@ -54,6 +54,7 @@ export default function NewSessionPage() {
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
useSettingsCommand()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
+35 -20
View File
@@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
@@ -58,6 +57,9 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@@ -153,13 +155,25 @@ export function SessionPage() {
// workspace-scoped state (terminal, directory providers) lives below.
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
// Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary.
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</TargetServerScopedProviders>
)
}
function TargetSessionSettingsCommand() {
useSettingsCommand()
return null
}
export function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
@@ -248,19 +262,17 @@ function ResolvedTargetSessionRoute() {
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
{/* Non-keyed: closes only while the target's directory is unknown (uncached
lineage mid-resolution), which tears down the workspace subtree including
the terminal. Same-workspace tab switches keep it open because warm
targets resolve synchronously from the sync cache. */}
<Show when={directory()}>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</TargetServerScopedProviders>
// Non-keyed: closes only while the target's directory is unknown (uncached
// lineage mid-resolution), which tears down the workspace subtree including
// the terminal. Same-workspace tab switches keep it open because warm
// targets resolve synchronously from the sync cache.
<Show when={directory()}>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
)
}
@@ -281,10 +293,10 @@ function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
return (
<PermissionProvider directory={props.directory}>
<>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
</>
)
}
@@ -1054,7 +1066,10 @@ export default function Page() {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return
inputRef?.focus()
const input = inputRef
if (!input) return
input.focus()
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
}
}
+1 -1
View File
@@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
media={{
mode: "auto",
path: path(),
@@ -28,7 +28,6 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
const delays = [120, 240]
const layout = useLayout()
const terminal = useTerminal()
const sdk = useSDK()
@@ -46,6 +45,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
let root: HTMLDivElement | undefined
let tabList: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({
autoCreated: false,
recovered: {} as Record<string, boolean>,
@@ -94,36 +95,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
),
)
const focus = (id: string) => {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = delays.map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
)
return () => {
cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer)
}
}
createEffect(
on(
() => [opened(), terminal.active()] as const,
([next, id]) => {
if (!next || !id) return
const stop = focus(id)
onCleanup(stop)
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
([next, id, requested]) => {
if (!next || !id || !requested) return
focusTerminalById(id)
},
),
)
@@ -285,7 +262,15 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
onChange={(id) => terminal.open(id)}
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
>
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
<Tabs.List
ref={tabList}
class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}>
{(pty, index) => (
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
@@ -304,7 +289,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
@@ -326,7 +311,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</TooltipV2>
@@ -344,7 +329,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
autoFocus={opened()}
autoFocus={terminal.focusRequested(id)}
onAutoFocus={() => terminal.consumeFocus(id)}
class="!px-[14px]"
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
@@ -38,6 +38,8 @@ export function TerminalPanel() {
const close = () => view().terminal.close()
let root: HTMLDivElement | undefined
onCleanup(() => terminal.cancelFocus())
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
@@ -285,7 +287,7 @@ export function TerminalPanel() {
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
onClick={() => terminal.new()}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
@@ -303,6 +305,7 @@ export function TerminalPanel() {
<Terminal
pty={pty()}
autoFocus={opened()}
onAutoFocus={() => terminal.consumeFocus(id)}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
@@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
@@ -408,6 +409,7 @@ export function MessageTimeline(props: {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
@@ -0,0 +1,199 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const child = document.createElement("div")
const sibling = document.createElement("div")
route.append(viewport)
viewport.append(child)
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
expect(mutationNodesContainElement([route], viewport)).toBe(true)
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const unrelated = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
cleanup?.()
route.remove()
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 20,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
calls.push(offset)
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
})
test.each([
{ name: "LTR", isRtl: false, expected: 240 },
{ name: "RTL", isRtl: true, expected: -240 },
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
viewport.scrollLeft = 240
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: true,
isRtl,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[expected, false]])
cleanup?.()
route.remove()
})
test("cleanup suppresses an already queued delegated offset callback", async () => {
const viewport = document.createElement("div")
document.body.append(viewport)
viewport.scrollTop = 100
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 10,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
calls.push([offset, isScrolling]),
)
viewport.dispatchEvent(new Event("scroll"))
cleanup?.()
await new Promise((resolve) => setTimeout(resolve, 25))
expect(calls).toEqual([[100, true]])
viewport.remove()
})
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 50,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
cleanup?.()
instance.scrollOffset = 100
viewport.dispatchEvent(new Event("scroll"))
await frames(4)
expect(calls).toEqual([])
route.remove()
})
async function frames(count: number) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
@@ -0,0 +1,73 @@
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
instance: Virtualizer<TScrollElement, TItemElement>,
callback: (offset: number, isScrolling: boolean) => void,
) {
let active = true
const deliver = (offset: number, isScrolling: boolean) => {
if (!active) return
callback(offset, isScrolling)
}
const cleanupOffset = observeElementOffset(instance, deliver)
const element = instance.scrollElement
const targetWindow = instance.targetWindow
const root = element?.closest("main") ?? element?.ownerDocument.body
if (!element || !targetWindow || !root)
return () => {
active = false
cleanupOffset?.()
}
let removed = false
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
targetWindow.cancelAnimationFrame(frame)
frame = undefined
}
const startCheck = () => {
clearCheck()
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
let framesAfterDeadline = 0
const check = (time: number) => {
frame = undefined
if (element.isConnected) {
const offset = instance.options.horizontal
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
: element.scrollTop
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
}
if (time >= deadline) framesAfterDeadline += 1
if (framesAfterDeadline >= 2) return
frame = targetWindow.requestAnimationFrame(check)
}
frame = targetWindow.requestAnimationFrame(check)
}
const observer = new targetWindow.MutationObserver((records) => {
if (!active) return
records.forEach((record) => {
if (record.target === element || element.contains(record.target)) return
if (mutationNodesContainElement(record.removedNodes, element)) {
removed = true
clearCheck()
}
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
removed = false
startCheck()
})
})
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
observer.observe(root, { childList: true, subtree: true })
return () => {
active = false
observer.disconnect()
clearCheck()
cleanupOffset?.()
}
}
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
return [...nodes].some((node) => node === element || node.contains(element))
}
@@ -264,7 +264,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
view().terminal.open()
}
@@ -498,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
onSelect: () => {
if (view().terminal.opened()) {
terminal.cancelFocus()
view().terminal.close()
return
}
terminal.requestFocus(terminal.active())
view().terminal.open()
},
}),
viewCommand({
id: "review.toggle",
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
export function createReviewPanelV2State() {
const [store, setStore] = persisted(
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
sidebarOpened: true,
@@ -24,6 +24,7 @@ export function createReviewPanelV2State() {
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
sidebarTransition: ready,
filter,
setFilter,
expandMode: () => store.expandMode,
@@ -200,6 +200,7 @@ function ReviewPanelV2Sidebar(props: {
return (
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
transition={props.state.sidebarTransition()}
title={props.title}
stats={<DiffChanges changes={props.diffs()} />}
filter={props.state.filter()}
@@ -19,6 +19,7 @@ const emptyFiles: string[] = []
export type SessionFileBrowserState = {
sidebarOpened: () => boolean
sidebarWidth: () => number
sidebarTransition: () => boolean
resizeSidebar: (width: number) => void
toggleSidebar: () => void
}
@@ -96,6 +97,7 @@ export function SessionFileBrowserTab(props: {
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
transition={props.state.sidebarTransition()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
@@ -0,0 +1,65 @@
import { beforeAll, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
let read: ((value: string | null) => void) | undefined
const storage: AsyncStorage = {
getItem: () => new Promise((resolve) => (read = resolve)),
setItem: async () => undefined,
removeItem: async () => undefined,
clear: async () => undefined,
key: async () => null,
getLength: async () => 0,
length: Promise.resolve(0),
}
beforeAll(async () => {
mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
})
test("enables sidebar motion only after custom width hydration", async () => {
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const state = createReviewPanelV2State()
const transition =
"sidebarTransition" in state && typeof state.sidebarTransition === "function"
? (state.sidebarTransition as () => boolean)
: undefined
try {
expect(transition).toBeFunction()
expect(transition?.()).toBeFalse()
expect(state.sidebarWidth()).toBe(240)
} catch (error) {
dispose()
reject(error)
return
}
createEffect(() => {
if (!transition?.()) return
try {
expect(state.sidebarWidth()).toBe(360)
dispose()
resolve()
} catch (error) {
dispose()
reject(error)
}
})
read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" }))
})
})
})
+44 -17
View File
@@ -1,8 +1,32 @@
import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 5,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 200 },
anchorTo: "end",
scrollEndThreshold: 1,
getScrollElement: () => null,
scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
observeElementRect: () => {},
observeElementOffset: () => {},
})
virtualizer.getTotalSize()
virtualizer.resizeItem(4, 120)
expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
writes.length = 0
virtualizer.resizeItem(4, 200)
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
})
})
test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 2,
getScrollElement: () => null,
estimateSize: () => 60,
initialOffset: 100,
initialRect: { width: 800, height: 60 },
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
test("clamps oversized offsets with scroll margin and padding changes", () => {
const options = (paddingEnd: number) => ({
count: 20,
estimateSize: () => 60,
initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.setOptions(options(600))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.18.1",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.18.1",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.18.1",
"type": "module",
"license": "MIT",
"scripts": {
+2
View File
@@ -9,6 +9,7 @@ import "./app.css"
import { LanguageProvider } from "~/context/language"
import { I18nProvider, useI18n } from "~/context/i18n"
import { strip } from "~/lib/language"
import { DesktopPromo } from "~/component/desktop-promo"
function AppMeta() {
const i18n = useI18n()
@@ -33,6 +34,7 @@ export default function App() {
<MetaProvider>
<AppMeta />
<Suspense>{props.children}</Suspense>
<DesktopPromo />
</MetaProvider>
</I18nProvider>
</LanguageProvider>
@@ -0,0 +1,103 @@
[data-component="desktop-promo"] {
--promo-background: hsl(0, 20%, 99%);
--promo-background-weak: hsl(0, 8%, 97%);
--promo-text: hsl(0, 1%, 39%);
--promo-text-strong: hsl(0, 5%, 12%);
--promo-border: hsla(0, 100%, 3%, 0.12);
position: fixed;
z-index: 20;
right: 1.5rem;
bottom: 1.5rem;
width: min(28rem, calc(100vw - 2rem));
padding: 4px;
overflow: hidden;
color: var(--promo-text);
border: 1px solid var(--promo-border);
border-radius: 8px;
background: var(--promo-background);
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%);
font-family: var(--font-mono);
@media (prefers-color-scheme: dark) {
--promo-background: hsl(0, 9%, 7%);
--promo-background-weak: hsl(0, 6%, 10%);
--promo-text: hsl(0, 4%, 71%);
--promo-text-strong: hsl(0, 15%, 94%);
--promo-border: hsl(0, 4%, 23%);
}
@media (max-width: 40rem) {
right: 1rem;
bottom: 1rem;
}
[data-slot="desktop-promo-link"] {
display: block;
color: var(--promo-text);
text-decoration: none;
}
[data-slot="desktop-promo-link"]:focus-visible {
outline: 2px solid var(--promo-text-strong);
outline-offset: -3px;
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--promo-background-weak);
}
[data-slot="desktop-promo-copy"] {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem;
font-size: 0.875rem;
line-height: 1.4;
}
[data-slot="desktop-promo-copy"] strong {
color: var(--promo-text-strong);
font-weight: 500;
}
[data-slot="desktop-promo-close"] {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: grid;
width: 2rem;
height: 2rem;
padding: 0;
place-items: center;
cursor: pointer;
color: white;
border: none;
border-radius: 0.25rem;
background: rgb(0 0 0 / 70%);
opacity: 0;
transition:
opacity 150ms ease,
background 150ms ease;
}
&:hover [data-slot="desktop-promo-close"],
[data-slot="desktop-promo-close"]:focus-visible {
opacity: 1;
}
[data-slot="desktop-promo-close"]:hover {
background: rgb(0 0 0 / 90%);
}
@media (hover: none) {
[data-slot="desktop-promo-close"] {
opacity: 1;
}
}
}
@@ -0,0 +1,48 @@
import "./desktop-promo.css"
import { A, useLocation } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { strip } from "~/lib/language"
export function DesktopPromo() {
const i18n = useI18n()
const language = useLanguage()
const location = useLocation()
const [visible, setVisible] = createSignal(true)
const request = getRequestEvent()?.request
const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : ""
const primaryHost =
hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
return (
<Show
when={
visible() &&
primaryHost &&
strip(location.pathname) !== "/download" &&
!strip(location.pathname).startsWith("/download/")
}
>
<aside data-component="desktop-promo">
<A href={language.route("/download")} data-slot="desktop-promo-link">
<video src={desktopPromoVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
<span data-slot="desktop-promo-copy">
<strong>{i18n.t("home.promo.title")}</strong>
<span>
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
</span>
</span>
</A>
<button type="button" data-slot="desktop-promo-close" onClick={() => setVisible(false)}>
<span class="sr-only">{i18n.t("home.promo.close")}</span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
</aside>
</Show>
)
}
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "شعار opencode الداكن",
"home.banner.badge": "جديد",
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
"home.banner.platforms": "على macOS، Windows، وLinux",
"home.banner.text": "نقدّم علامات التبويب لتطبيق سطح المكتب.",
"home.banner.platforms": "متاحة على macOS وWindows وLinux",
"home.banner.downloadNow": "حمّل الآن",
"home.banner.downloadBetaNow": "حمّل النسخة التجريبية لتطبيق سطح المكتب الآن",
"home.promo.title": "نقدّم علامات التبويب لتطبيق سطح المكتب",
"home.promo.body": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب.",
"home.promo.cta": "نزّل أحدث إصدار للبدء.",
"home.promo.close": "إغلاق إعلان تطبيق سطح المكتب",
"home.hero.title": "وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
"home.hero.subtitle.a": "نماذج مجانية مضمّنة أو اربط أي نموذج من أي مزوّد،",
@@ -700,11 +704,11 @@ export const dict = {
"download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
"download.hero.title": "تنزيل OpenCode",
"download.hero.title": "تنزيل OpenCode لسطح المكتب",
"download.hero.subtitle": "متاح في نسخة تجريبية لـ macOS، Windows، وLinux",
"download.hero.button": "تنزيل لـ {{os}}",
"download.section.terminal": "OpenCode للطرفية",
"download.section.desktop": "OpenCode لسطح المكتب (Beta)",
"download.section.desktop": "OpenCode لسطح المكتب",
"download.section.extensions": "امتدادات OpenCode",
"download.section.integrations": "تكاملات OpenCode",
"download.action.download": "تنزيل",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "logo opencode escuro",
"home.banner.badge": "Novo",
"home.banner.text": "App desktop disponível em beta",
"home.banner.platforms": "no macOS, Windows e Linux",
"home.banner.text": "Conheça as abas no app desktop.",
"home.banner.platforms": "Disponível no macOS, Windows e Linux",
"home.banner.downloadNow": "Baixar agora",
"home.banner.downloadBetaNow": "Baixe agora o beta do desktop",
"home.promo.title": "Conheça as abas no app desktop",
"home.promo.body": "Organize seu trabalho e suas sessões ativas com abas.",
"home.promo.cta": "Baixe a versão mais recente para começar.",
"home.promo.close": "Fechar anúncio do app desktop",
"home.hero.title": "O agente de codificação de código aberto com IA",
"home.hero.subtitle.a": "Modelos grátis incluídos ou conecte qualquer modelo de qualquer provedor,",
@@ -711,11 +715,11 @@ export const dict = {
"download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
"download.hero.title": "Baixar OpenCode",
"download.hero.title": "Baixar OpenCode Desktop",
"download.hero.subtitle": "Disponível em Beta para macOS, Windows e Linux",
"download.hero.button": "Baixar para {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "Extensões OpenCode",
"download.section.integrations": "Integrações OpenCode",
"download.action.download": "Baixar",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode logo dark",
"home.banner.badge": "Ny",
"home.banner.text": "Desktop-app tilgængelig i beta",
"home.banner.platforms": "på macOS, Windows og Linux",
"home.banner.text": "Introduktion af Tabs til Desktop.",
"home.banner.platforms": "Tilgængelig på macOS, Windows og Linux",
"home.banner.downloadNow": "Download nu",
"home.banner.downloadBetaNow": "Download desktop-betaen nu",
"home.promo.title": "Introduktion af Tabs til Desktop",
"home.promo.body": "Organiser dit arbejde og aktive sessioner med faner.",
"home.promo.cta": "Download den nyeste version for at komme i gang.",
"home.promo.close": "Luk meddelelsen om Desktop-appen",
"home.hero.title": "Den open source AI-kodningsagent",
"home.hero.subtitle.a": "Gratis modeller inkluderet, eller forbind enhver model fra enhver udbyder,",
@@ -706,11 +710,11 @@ export const dict = {
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
"download.hero.title": "Download OpenCode",
"download.hero.title": "Download OpenCode Desktop",
"download.hero.subtitle": "Tilgængelig i beta til macOS, Windows og Linux",
"download.hero.button": "Download til {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrations",
"download.action.download": "Download",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "OpenCode Logo dunkel",
"home.banner.badge": "Neu",
"home.banner.text": "Desktop-App in der Beta verfügbar",
"home.banner.platforms": "auf macOS, Windows und Linux",
"home.banner.text": "Neu: Tabs für Desktop.",
"home.banner.platforms": "Verfügbar für macOS, Windows und Linux",
"home.banner.downloadNow": "Jetzt herunterladen",
"home.banner.downloadBetaNow": "Desktop-Beta jetzt herunterladen",
"home.promo.title": "Neu: Tabs für Desktop",
"home.promo.body": "Organisiere deine Arbeit und aktiven Sitzungen mit Tabs.",
"home.promo.cta": "Lade die neueste Version herunter, um loszulegen.",
"home.promo.close": "Ankündigung zur Desktop-App schließen",
"home.hero.title": "Der Open-Source AI-Coding-Agent",
"home.hero.subtitle.a": "Kostenlose Modelle inklusive oder verbinde jedes Modell eines beliebigen Anbieters,",
@@ -711,11 +715,11 @@ export const dict = {
"download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
"download.hero.title": "OpenCode herunterladen",
"download.hero.title": "OpenCode Desktop herunterladen",
"download.hero.subtitle": "In Beta verfügbar für macOS, Windows und Linux",
"download.hero.button": "Download für {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrationen",
"download.action.download": "Download",
+9 -4
View File
@@ -102,11 +102,16 @@ export const dict = {
"temp.logoDarkAlt": "opencode logo dark",
"home.banner.badge": "New",
"home.banner.text": "Desktop app available in beta",
"home.banner.platforms": "on macOS, Windows, and Linux",
"home.banner.text": "Introducing tabs for desktop.",
"home.banner.platforms": "Available on macOS, Windows, and Linux",
"home.banner.downloadNow": "Download now",
"home.banner.downloadBetaNow": "Download the desktop beta now",
"home.promo.title": "Introducing Tabs for Desktop",
"home.promo.body": "Organize your work and active sessions with tabs.",
"home.promo.cta": "Download the latest to get started.",
"home.promo.close": "Dismiss desktop app announcement",
"home.hero.title": "The open source AI coding agent",
"home.hero.subtitle.a": "Free models included or connect any model from any provider,",
"home.hero.subtitle.b": "including Claude, GPT, Gemini and more.",
@@ -703,11 +708,11 @@ export const dict = {
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
"download.hero.title": "Download OpenCode",
"download.hero.title": "Download OpenCode Desktop",
"download.hero.subtitle": "Available in Beta for macOS, Windows, and Linux",
"download.hero.button": "Download for {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrations",
"download.action.download": "Download",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "logo de opencode oscuro",
"home.banner.badge": "Nuevo",
"home.banner.text": "Aplicación de escritorio disponible en beta",
"home.banner.platforms": "en macOS, Windows y Linux",
"home.banner.text": "Presentamos Tabs para Desktop.",
"home.banner.platforms": "Disponible en macOS, Windows y Linux",
"home.banner.downloadNow": "Descargar ahora",
"home.banner.downloadBetaNow": "Descargar la beta de escritorio ahora",
"home.promo.title": "Presentamos Tabs para Desktop",
"home.promo.body": "Organiza tu trabajo y tus sesiones activas con pestañas.",
"home.promo.cta": "Descarga la última versión para empezar.",
"home.promo.close": "Cerrar el anuncio de la aplicación Desktop",
"home.hero.title": "El agente de codificación IA de código abierto",
"home.hero.subtitle.a": "Modelos gratuitos incluidos o conecta cualquier modelo de cualquier proveedor,",
@@ -711,11 +715,11 @@ export const dict = {
"download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
"download.hero.title": "Descargar OpenCode",
"download.hero.title": "Descargar OpenCode Desktop",
"download.hero.subtitle": "Disponible en Beta para macOS, Windows y Linux",
"download.hero.button": "Descargar para {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "Extensiones OpenCode",
"download.section.integrations": "Integraciones OpenCode",
"download.action.download": "Descargar",
+8 -4
View File
@@ -100,10 +100,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode logo dark",
"home.banner.badge": "Nouveau",
"home.banner.text": "Application desktop disponible en bêta",
"home.banner.platforms": "sur macOS, Windows et Linux",
"home.banner.text": "Découvrez Tabs pour Desktop.",
"home.banner.platforms": "Disponible sur macOS, Windows et Linux",
"home.banner.downloadNow": "Télécharger maintenant",
"home.banner.downloadBetaNow": "Télécharger la bêta desktop maintenant",
"home.promo.title": "Découvrez Tabs pour Desktop",
"home.promo.body": "Organisez votre travail et vos sessions actives avec des onglets.",
"home.promo.cta": "Téléchargez la dernière version pour commencer.",
"home.promo.close": "Fermer lannonce de lapplication Desktop",
"home.hero.title": "L'agent de code IA open source",
"home.hero.subtitle.a":
@@ -718,11 +722,11 @@ export const dict = {
"download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
"download.hero.title": "Télécharger OpenCode",
"download.hero.title": "Télécharger OpenCode Desktop",
"download.hero.subtitle": "Disponible en bêta pour macOS, Windows et Linux",
"download.hero.button": "Télécharger pour {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Bêta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "Extensions OpenCode",
"download.section.integrations": "Intégrations OpenCode",
"download.action.download": "Télécharger",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "logo scuro di opencode",
"home.banner.badge": "Nuovo",
"home.banner.text": "App desktop disponibile in beta",
"home.banner.platforms": "su macOS, Windows e Linux",
"home.banner.text": "Scopri Tabs per Desktop.",
"home.banner.platforms": "Disponibile su macOS, Windows e Linux",
"home.banner.downloadNow": "Scarica ora",
"home.banner.downloadBetaNow": "Scarica ora la beta desktop",
"home.promo.title": "Scopri Tabs per Desktop",
"home.promo.body": "Organizza il tuo lavoro e le sessioni attive con le schede.",
"home.promo.cta": "Scarica lultima versione per iniziare.",
"home.promo.close": "Chiudi lannuncio dellapp Desktop",
"home.hero.title": "L'agente di coding IA open source",
"home.hero.subtitle.a": "Modelli gratuiti inclusi o collega qualsiasi modello da qualsiasi provider,",
@@ -709,11 +713,11 @@ export const dict = {
"download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
"download.hero.title": "Scarica OpenCode",
"download.hero.title": "Scarica OpenCode Desktop",
"download.hero.subtitle": "Disponibile in Beta per macOS, Windows e Linux",
"download.hero.button": "Scarica per {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrations",
"download.action.download": "Scarica",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
"home.banner.badge": "新着",
"home.banner.text": "デスクトップアプリのベータ版が利用可能",
"home.banner.platforms": "macOS、Windows、Linux で",
"home.banner.text": "デスクトップ版にタブが登場。",
"home.banner.platforms": "macOS、Windows、Linux で利用できます",
"home.banner.downloadNow": "今すぐダウンロード",
"home.banner.downloadBetaNow": "デスクトップベータ版を今すぐダウンロード",
"home.promo.title": "デスクトップ版にタブが登場",
"home.promo.body": "タブで作業とアクティブなセッションを整理できます。",
"home.promo.cta": "始めるには最新版をダウンロードしてください。",
"home.promo.close": "デスクトップアプリのお知らせを閉じる",
"home.hero.title": "オープンソースのAIコーディングエージェント",
"home.hero.subtitle.a": "無料モデルが含まれています。また、任意のプロバイダーの任意のモデルに接続でき、",
@@ -708,11 +712,11 @@ export const dict = {
"download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
"download.hero.title": "OpenCode をダウンロード",
"download.hero.title": "OpenCode デスクトップ版をダウンロード",
"download.hero.subtitle": "macOS、Windows、Linux 向けベータ版を利用可能",
"download.hero.button": "{{os}} 向けダウンロード",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrations",
"download.action.download": "ダウンロード",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode 어두운 로고",
"home.banner.badge": "신규",
"home.banner.text": "데스크톱 앱 베타 버전 출시",
"home.banner.platforms": "macOS, Windows, Linux 지원",
"home.banner.text": "데스크톱 탭을 소개합니다.",
"home.banner.platforms": "macOS, Windows, Linux에서 사용할 수 있습니다",
"home.banner.downloadNow": "지금 다운로드",
"home.banner.downloadBetaNow": "데스크톱 베타 다운로드",
"home.promo.title": "데스크톱 탭을 소개합니다",
"home.promo.body": "탭으로 작업과 활성 세션을 정리하세요.",
"home.promo.cta": "최신 버전을 다운로드하여 시작하세요.",
"home.promo.close": "데스크톱 앱 안내 닫기",
"home.hero.title": "오픈 소스 AI 코딩 에이전트",
"home.hero.subtitle.a": "무료 모델이 포함되어 있으며, 어떤 제공자의 모델이든 연결 가능합니다.",
@@ -700,11 +704,11 @@ export const dict = {
"download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
"download.hero.title": "OpenCode 다운로드",
"download.hero.title": "OpenCode 데스크톱 다운로드",
"download.hero.subtitle": "macOS, Windows, Linux용 베타 버전 사용 가능",
"download.hero.button": "{{os}}용 다운로드",
"download.section.terminal": "OpenCode 터미널",
"download.section.desktop": "OpenCode 데스크톱 (베타)",
"download.section.desktop": "OpenCode 데스크톱",
"download.section.extensions": "OpenCode 확장 프로그램",
"download.section.integrations": "OpenCode 통합",
"download.action.download": "다운로드",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode logo mørk",
"home.banner.badge": "Ny",
"home.banner.text": "Desktop-app tilgjengelig i beta",
"home.banner.platforms": "på macOS, Windows og Linux",
"home.banner.text": "Vi introduserer Tabs for Desktop.",
"home.banner.platforms": "Tilgjengelig på macOS, Windows og Linux",
"home.banner.downloadNow": "Last ned nå",
"home.banner.downloadBetaNow": "Last ned desktop-betaen nå",
"home.promo.title": "Vi introduserer Tabs for Desktop",
"home.promo.body": "Organiser arbeidet ditt og aktive økter med faner.",
"home.promo.cta": "Last ned den nyeste versjonen for å komme i gang.",
"home.promo.close": "Lukk kunngjøringen om Desktop-appen",
"home.hero.title": "Den åpne kildekode AI-kodingsagenten",
"home.hero.subtitle.a":
@@ -707,11 +711,11 @@ export const dict = {
"download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
"download.hero.title": "Last ned OpenCode",
"download.hero.title": "Last ned OpenCode Desktop",
"download.hero.subtitle": "Tilgjengelig i beta for macOS, Windows og Linux",
"download.hero.button": "Last ned for {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Extensions",
"download.section.integrations": "OpenCode Integrations",
"download.action.download": "Last ned",
+8 -4
View File
@@ -101,10 +101,14 @@ export const dict = {
"temp.logoDarkAlt": "ciemne logo opencode",
"home.banner.badge": "Nowość",
"home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
"home.banner.platforms": "na macOS, Windows i Linux",
"home.banner.text": "Przedstawiamy karty w aplikacji desktopowej.",
"home.banner.platforms": "Dostępne na macOS, Windows i Linux",
"home.banner.downloadNow": "Pobierz teraz",
"home.banner.downloadBetaNow": "Pobierz betę wersji desktopowej",
"home.promo.title": "Przedstawiamy karty w aplikacji desktopowej",
"home.promo.body": "Organizuj swoją pracę i aktywne sesje za pomocą kart.",
"home.promo.cta": "Pobierz najnowszą wersję, aby rozpocząć.",
"home.promo.close": "Zamknij ogłoszenie o aplikacji desktopowej",
"home.hero.title": "Open source'owy agent AI do kodowania",
"home.hero.subtitle.a": "Darmowe modele w zestawie lub podłącz dowolny model od dowolnego dostawcy,",
@@ -708,11 +712,11 @@ export const dict = {
"download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
"download.hero.title": "Pobierz OpenCode",
"download.hero.title": "Pobierz OpenCode Desktop",
"download.hero.subtitle": "Dostępne w wersji Beta na macOS, Windows i Linux",
"download.hero.button": "Pobierz na {{os}}",
"download.section.terminal": "Terminal OpenCode",
"download.section.desktop": "Pulpit OpenCode (Beta)",
"download.section.desktop": "Pulpit OpenCode",
"download.section.extensions": "Rozszerzenia OpenCode",
"download.section.integrations": "Integracje OpenCode",
"download.action.download": "Pobierz",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "темный логотип opencode",
"home.banner.badge": "Новое",
"home.banner.text": "Доступно десктопное приложение (бета)",
"home.banner.platforms": "на macOS, Windows и Linux",
"home.banner.text": "Представляем вкладки в десктопном приложении.",
"home.banner.platforms": "Доступно на macOS, Windows и Linux",
"home.banner.downloadNow": "Скачать",
"home.banner.downloadBetaNow": "Скачать бету для десктопа",
"home.promo.title": "Представляем вкладки в десктопном приложении",
"home.promo.body": "Организуйте работу и активные сессии с помощью вкладок.",
"home.promo.cta": "Скачайте последнюю версию, чтобы начать.",
"home.promo.close": "Закрыть объявление о десктопном приложении",
"home.hero.title": "AI-агент с открытым кодом для программирования",
"home.hero.subtitle.a": "Бесплатные модели включены, или подключите любую модель от любого провайдера,",
@@ -716,11 +720,11 @@ export const dict = {
"download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
"download.hero.title": "Скачать OpenCode",
"download.hero.title": "Скачать OpenCode Desktop",
"download.hero.subtitle": "Доступна бета для macOS, Windows и Linux",
"download.hero.button": "Скачать для {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "Расширения OpenCode",
"download.section.integrations": "Интеграции OpenCode",
"download.action.download": "Скачать",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
"home.banner.badge": "ใหม่",
"home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
"home.banner.platforms": "บน macOS, Windows และ Linux",
"home.banner.text": "ขอแนะนำแท็บสำหรับเดสก์ท็อป.",
"home.banner.platforms": "พร้อมใช้งานบน macOS, Windows และ Linux",
"home.banner.downloadNow": "ดาวน์โหลดตอนนี้",
"home.banner.downloadBetaNow": "ดาวน์โหลดเบต้าเดสก์ท็อปตอนนี้",
"home.promo.title": "ขอแนะนำแท็บสำหรับเดสก์ท็อป",
"home.promo.body": "จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ",
"home.promo.cta": "ดาวน์โหลดเวอร์ชันล่าสุดเพื่อเริ่มต้นใช้งาน",
"home.promo.close": "ปิดประกาศเกี่ยวกับแอปเดสก์ท็อป",
"home.hero.title": "เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
"home.hero.subtitle.a": "มีโมเดลฟรีรวมอยู่ หรือเชื่อมต่อโมเดลใดก็ได้จากผู้ให้บริการรายใดก็ได้,",
@@ -703,11 +707,11 @@ export const dict = {
"download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
"download.hero.title": "ดาวน์โหลด OpenCode",
"download.hero.title": "ดาวน์โหลด OpenCode สำหรับเดสก์ท็อป",
"download.hero.subtitle": "พร้อมใช้งานในเวอร์ชันเบต้าสำหรับ macOS, Windows และ Linux",
"download.hero.button": "ดาวน์โหลดสำหรับ {{os}}",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "ส่วนขยาย OpenCode",
"download.section.integrations": "การเชื่อมต่อ OpenCode",
"download.action.download": "ดาวน์โหลด",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode koyu logo",
"home.banner.badge": "Yeni",
"home.banner.text": "Masaüstü uygulaması beta olarak mevcut",
"home.banner.platforms": "macOS, Windows ve Linux'ta",
"home.banner.text": "Masaüstü uygulamasında sekmelerle tanışın.",
"home.banner.platforms": "macOS, Windows ve Linux'ta kullanılabilir",
"home.banner.downloadNow": "Şimdi indir",
"home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
"home.promo.title": "Masaüstü uygulamasında sekmelerle tanışın",
"home.promo.body": "Çalışmalarınızı ve etkin oturumlarınızı sekmelerle düzenleyin.",
"home.promo.cta": "Başlamak için en son sürümü indirin.",
"home.promo.close": "Masaüstü uygulaması duyurusunu kapat",
"home.hero.title": "Açık kaynaklı yapay zeka kodlama ajanı",
"home.hero.subtitle.a": "Ücretsiz modeller dahil veya herhangi bir sağlayıcıdan herhangi bir modeli bağlayın,",
@@ -711,11 +715,11 @@ export const dict = {
"download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
"download.hero.title": "OpenCode'u İndir",
"download.hero.title": "OpenCode Desktop'u İndir",
"download.hero.subtitle": "macOS, Windows ve Linux için Beta olarak sunuluyor",
"download.hero.button": "{{os}} için indir",
"download.section.terminal": "OpenCode Terminal",
"download.section.desktop": "OpenCode Desktop (Beta)",
"download.section.desktop": "OpenCode Desktop",
"download.section.extensions": "OpenCode Eklentileri",
"download.section.integrations": "OpenCode Entegrasyonları",
"download.action.download": "İndir",
+8 -4
View File
@@ -102,10 +102,14 @@ export const dict = {
"temp.logoDarkAlt": "темний логотип opencode",
"home.banner.badge": "Нове",
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
"home.banner.platforms": "на macOS, Windows та Linux",
"home.banner.text": "Представляємо вкладки в десктопному застосунку.",
"home.banner.platforms": "Доступно на macOS, Windows та Linux",
"home.banner.downloadNow": "Завантажити зараз",
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
"home.promo.title": "Представляємо вкладки в десктопному застосунку",
"home.promo.body": "Упорядковуйте роботу й активні сесії за допомогою вкладок.",
"home.promo.cta": "Завантажте останню версію, щоб почати.",
"home.promo.close": "Закрити оголошення про десктопний застосунок",
"home.hero.title": "Відкритий AI-агент для кодування",
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
@@ -670,11 +674,11 @@ export const dict = {
"download.title": "OpenCode | Завантажити",
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
"download.hero.title": "Завантажити OpenCode",
"download.hero.title": "Завантажити OpenCode Desktop",
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
"download.hero.button": "Завантажити для {{os}}",
"download.section.terminal": "Термінал OpenCode",
"download.section.desktop": "Десктоп OpenCode (Бета)",
"download.section.desktop": "Десктоп OpenCode",
"download.section.extensions": "Розширення OpenCode",
"download.section.integrations": "Інтеграції OpenCode",
"download.action.download": "Завантажити",
+8 -4
View File
@@ -103,10 +103,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode logo 暗色",
"home.banner.badge": "新",
"home.banner.text": "桌面应用 Beta 版现已推出",
"home.banner.platforms": "支持 macOS, Windows, 和 Linux",
"home.banner.text": "桌面版标签页现已推出",
"home.banner.platforms": "适用于 macOSWindows 和 Linux",
"home.banner.downloadNow": "立即下载",
"home.banner.downloadBetaNow": "立即下载桌面 Beta 版",
"home.promo.title": "桌面版标签页现已推出",
"home.promo.body": "使用标签页整理工作和活跃会话。",
"home.promo.cta": "下载最新版本即可开始使用。",
"home.promo.close": "关闭桌面应用公告",
"home.hero.title": "开源 AI 编程代理",
"home.hero.subtitle.a": "内置免费模型,或连接任意提供商的任意模型,",
@@ -682,11 +686,11 @@ export const dict = {
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
"download.hero.title": "下载 OpenCode",
"download.hero.title": "下载 OpenCode 桌面版",
"download.hero.subtitle": "适用于 macOS, Windows, 和 Linux 的 Beta 版",
"download.hero.button": "下载 {{os}} 版",
"download.section.terminal": "OpenCode 终端",
"download.section.desktop": "OpenCode 桌面版 (Beta)",
"download.section.desktop": "OpenCode 桌面版",
"download.section.extensions": "OpenCode 扩展",
"download.section.integrations": "OpenCode 集成",
"download.action.download": "下载",
+8 -4
View File
@@ -103,10 +103,14 @@ export const dict = {
"temp.logoDarkAlt": "opencode 深色標誌",
"home.banner.badge": "新",
"home.banner.text": "桌面應用已推出 Beta",
"home.banner.platforms": "支援 macOS、Windows 與 Linux",
"home.banner.text": "桌面版分頁功能全新推出。",
"home.banner.platforms": "適用於 macOS、Windows 與 Linux",
"home.banner.downloadNow": "立即下載",
"home.banner.downloadBetaNow": "立即下載桌面 Beta 版",
"home.promo.title": "桌面版分頁功能全新推出",
"home.promo.body": "使用分頁整理工作和作用中的工作階段。",
"home.promo.cta": "下載最新版本即可開始使用。",
"home.promo.close": "關閉桌面應用程式公告",
"home.hero.title": "開源 AI 編碼代理",
"home.hero.subtitle.a": "內建免費模型,或連接任意供應商的任意模型,",
@@ -682,11 +686,11 @@ export const dict = {
"download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
"download.hero.title": "下載 OpenCode",
"download.hero.title": "下載 OpenCode 桌面版",
"download.hero.subtitle": "適用於 macOS、Windows 與 Linux 的 Beta 版現已提供",
"download.hero.button": "下載 {{os}} 版",
"download.section.terminal": "OpenCode 終端",
"download.section.desktop": "OpenCode 桌面版Beta",
"download.section.desktop": "OpenCode 桌面版",
"download.section.extensions": "OpenCode 擴充功能",
"download.section.integrations": "OpenCode 整合",
"download.action.download": "下載",
+4 -2
View File
@@ -18,7 +18,8 @@ export async function statsProxy(evt: APIEvent) {
if (
targetUrl.pathname.startsWith(`${dataPath}/_build/`) ||
targetUrl.pathname === `${dataPath}/banner.jpg` ||
targetUrl.pathname === `${dataPath}/banner.png`
targetUrl.pathname === `${dataPath}/banner.png` ||
targetUrl.pathname === `${dataPath}/sitemap.xml`
) {
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
}
@@ -97,7 +98,8 @@ function isDataBypassPath(pathname: string) {
pathname.startsWith(`${dataPath}/api/`) ||
pathname.startsWith(`${dataPath}/_server`) ||
pathname === `${dataPath}/banner.jpg` ||
pathname === `${dataPath}/banner.png`
pathname === `${dataPath}/banner.png` ||
pathname === `${dataPath}/sitemap.xml`
)
}
@@ -284,7 +284,7 @@
}
[data-component="content"] {
padding: 6rem 5rem;
padding: 3rem 5rem 6rem;
@media (max-width: 60rem) {
padding: 4rem 1.5rem;
@@ -316,67 +316,43 @@
/* Download Hero Section */
[data-component="download-hero"] {
/* display: grid; */
display: none;
grid-template-columns: 260px 1fr;
display: grid;
grid-template-columns: 255px 1fr;
gap: 4rem;
padding-bottom: 2rem;
padding: 4px;
border: 1px solid var(--color-border-weak);
border-radius: 8px;
margin-bottom: 4rem;
@media (max-width: 50rem) {
grid-template-columns: 1fr;
gap: 1.5rem;
padding-bottom: 2rem;
gap: 0;
margin-bottom: 2rem;
}
[data-component="hero-icon"] {
display: flex;
justify-content: flex-end;
align-items: center;
@media (max-width: 40rem) {
display: none;
}
[data-slot="icon-placeholder"] {
width: 120px;
height: 120px;
background: var(--color-background-weak);
border: 1px solid var(--color-border-weak);
border-radius: 24px;
[data-component="hero-video"] {
video {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
@media (max-width: 50rem) {
width: 80px;
height: 80px;
aspect-ratio: 16 / 9;
}
}
img {
width: 120px;
height: 120px;
border-radius: 24px;
box-shadow:
0 1.467px 2.847px 0 rgba(0, 0, 0, 0.42),
0 0.779px 1.512px 0 rgba(0, 0, 0, 0.34),
0 0.324px 0.629px 0 rgba(0, 0, 0, 0.24);
@media (max-width: 50rem) {
width: 80px;
height: 80px;
border-radius: 16px;
}
}
@media (max-width: 50rem) {
justify-content: flex-start;
}
}
[data-component="hero-text"] {
display: flex;
flex-direction: column;
justify-content: center;
padding-right: 3rem;
@media (max-width: 50rem) {
padding: 1.5rem;
}
h1 {
font-size: 1.5rem;

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