Compare commits

...
Author SHA1 Message Date
Brendonovich 0f2b10993d fix(core): return formatted diff from write tool 2026-10-08 03:46:10 +00:00
Frank 8f4861875f docs(www): add Sonnet 5.5 to V2 model tables 2026-10-07 23:33:29 -04:00
Dax 6861df7638 fix(cli): reject stale platform binaries during install (#53845) 2026-10-07 23:05:35 -04:00
Dax 53533fd0bf fix(cli): share the device's OpenTunnel tunnel for remote access (#53844) 2026-10-07 23:03:55 -04:00
Luke Parker 3b684acd8d fix(app): anchor revealed tools under the sticky headers (#53832) 2026-10-08 12:48:24 +10:00
opencode-agent[bot] 5d05835023 chore: update nix node_modules hashes 2026-10-08 02:38:21 +00:00
Dax 0d07f915d2 feat(cli): pair remotely through OpenTunnel (#53837) 2026-10-07 22:29:50 -04:00
opencode-agent[bot] 216c311744 chore: update nix node_modules hashes 2026-10-08 00:25:58 +00:00
Kit Langton 6297cd5a0b feat(ui): animate segmented control with solid-motion and fix button hit-testing (#53825) 2026-10-07 17:16:00 -07:00
Kit Langton c28e9ec311 fix(cli): give plugins the host's Effect (#53422) 2026-10-07 16:57:24 -07:00
Luke Parker 46c69bc35c fix(session-ui): show latest thinking summary heading while streaming (#53815) 2026-10-08 08:24:05 +10:00
Aiden Cline be19c7612f fix(core): drop early guarded plugin activation (#53819) 2026-10-07 17:12:45 -05:00
Simon Klee 162bc6922b test: stabilize asynchronous UI and plugin tests (#53818) 2026-10-07 22:01:55 +00:00
Filip 87a1ebcc97 feat(core): connect Azure through the Azure CLI as an external credential (#53794) 2026-10-07 23:10:18 +02:00
opencode-agent[bot] 2138bfd02a chore: update nix node_modules hashes 2026-10-07 21:04:54 +00:00
Luke Parker 98d57d0dd2 fix(app): focus search input when opening /model dialog (#53707) 2026-10-08 06:56:27 +10:00
Simon Klee fcad1319bf chore: upgrade opentui v0.5.16 (#53805) 2026-10-07 20:54:17 +00:00
Dax Raad e3419d47ee fix(cli): keep state on uninstall 2026-10-07 16:45:59 -04:00
Adam d016db856c feat(core): enforce skill integration policies 2026-10-07 15:39:27 -05:00
Adam 613ef8ba87 feat(core): enforce managed integration and tool policies (#53775) 2026-10-07 15:39:26 -05:00
Dax Raad b8d3c92985 fix(cli): keep data and config on uninstall 2026-10-07 16:34:39 -04:00
opencode-agent[bot]andnexxeln 8587f817cc fix(tui): separate queued prompt dock from composer (#53801)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-10-08 01:45:54 +05:30
Simon Klee a98adc1ab0 fix(tui): stop dialogs reading stale transcript rows after resync (#53800) 2026-10-07 22:13:36 +02:00
Milosz JankiewiczandJaaneek 31bdd8e8f7 fix(core): only send png, jpeg and webp images to xAI (#53787)
Co-authored-by: Jaaneek <Jaaneek@users.noreply.github.com>
2026-10-07 21:46:09 +02:00
Frank cab7e2c242 docs(www): add Haiku 5.5 to V2 Go guide 2026-10-07 15:10:42 -04:00
Aiden Cline 56be008756 feat(core): add AWS profile setup for Bedrock (#53626) 2026-10-07 14:05:08 -05:00
Frank 554e7c2341 docs(www): add Haiku 5.5 to V2 model tables 2026-10-07 14:47:58 -04:00
opencode-agent[bot]andrekram1-node 5183ea45c3 fix(core): send Codex session headers with ChatGPT token sharing (#53767)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-07 11:32:57 -05:00
Dax 053e534e5a refactor(server): align pty connect preflight order (#53579) 2026-10-07 12:07:48 -04:00
opencode-agent[bot]andrekram1-node 8bd8960154 fix(ai): add missing OpenAI and Google service tiers (#53753)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-07 11:06:50 -05:00
holny d5154fb74e fix(core): bypass gray-matter content cache when parsing frontmatter (#51245) 2026-10-07 11:05:37 -05:00
Aiden Cline 70c6c8cbc5 feat(integration): add declarative external connection method (#53672) 2026-10-07 11:03:24 -05:00
NamitandJames Long e2979039f1 fix(core): bump session time_updated on step lifecycle events (#49105)
Co-authored-by: James Long <longster@gmail.com>
2026-10-07 11:40:47 -04:00
opencode-agent[bot]andBrendonovich 119cb15d1d refactor(app): remove configurable default server (#53731)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 20:46:51 +08:00
opencode-agent[bot] e1b7299bc9 chore(core): refresh bundled models.dev snapshot 2026-10-07 12:22:51 +00:00
opencode-agent[bot]andBrendonovich 68aa4f8017 feat(app): show recent sessions in the empty command palette (#53727)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:58:35 +00:00
David HillandBrendonovich 69219470a2 feat(app): show recently closed tabs menu (#51973)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:24:51 +00:00
opencode-agent[bot]andBrendonovich 10b11ba946 fix(app): keep live terminals through transient connect failures (#52232)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:04:03 +00:00
opencode-agent[bot]andBrendonovich 2e3d3a735a fix(app): use filled clear icon in model and branch searches (#53511)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 17:12:10 +08:00
opencode-agent[bot]andBrendonovich f655e7b2b5 fix(app): wrap mobile project controls responsively (#50484)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 17:11:57 +08:00
opencode-agent[bot]andneriousy 6273bd7924 fix(core): stop sending API key metadata as request body (#53546)
Co-authored-by: neriousy <filip@anoma.ly>
2026-10-07 10:36:22 +02:00
Aiden Cline 34cf183c4c feat(ai): support between_tools thinking in Anthropic Messages (#53601) 2026-10-06 23:57:20 -05:00
opencode-agent[bot]andBrendonovich fd99516e51 fix(session-ui): keep Markdown text selectable (#53665)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 04:33:34 +00:00
Aiden Cline 1d610f90a0 fix(ui): support inline custom answers for string choice fields in connect (#53625) 2026-10-06 22:41:21 -05:00
Luke Parker eebe9d370f fix(app): handle one-time pairing links across the GUI (#53257) 2026-10-07 12:56:07 +10:00
Luke Parker a2b6005627 fix(desktop): delete old staged CLI versions (#53659) 2026-10-07 02:10:05 +00:00
Luke Parker a90ad8566d fix(cli): serve the embedded web UI brotli-compressed (#53645) 2026-10-07 11:35:20 +10:00
Luke Parker a7f3d570ae fix(cli): compress the embedded web UI at brotli's best quality (#53644) 2026-10-07 11:35:20 +10:00
Luke Parker 8b45c5674b fix(cli): embed the web UI as raw bytes instead of base64 strings (#53643) 2026-10-07 11:35:19 +10:00
Frank 56c2f432cc docs(www): sync Mistral Large 4 pricing and privacy 2026-10-06 21:27:39 -04:00
Kit Langton 74678c4677 feat(server): stream fs.read responses with HTTP Range support (#53088) 2026-10-06 18:24:55 -07:00
Aiden Cline 369aad308b feat(core): add external credential references (#53624) 2026-10-06 19:55:05 -05:00
opencode-agent[bot] d5cf9c2548 chore: update nix node_modules hashes 2026-10-07 00:40:27 +00:00
Luke Parker 9c51d84f8f feat(app): preview Word, Excel and PowerPoint files (#53305) 2026-10-07 00:31:01 +00:00
Aiden Cline 8b02c4ef04 fix(ai): drop empty unfinished reasoning items on replay (#53603) 2026-10-06 19:03:35 -05:00
Luke Parker e41256eb5a fix(desktop): fix browser bar review findings (#53627) 2026-10-07 09:50:13 +10:00
Daniel Chen d1749770c7 docs(www): add Exo Free to Console models (#53620) 2026-10-06 15:27:58 -07:00
Kit Langton 9ee97d87a2 fix(sdk): release completed embedded request signals (#53613) 2026-10-06 15:05:36 -07:00
59e5953443 fix(schema): expose effort-update and thinking-binding overrides on model compatibility (#52172)
Co-authored-by: argszero <argszero@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-06 15:41:12 -05:00
Dax 4a0f273fa7 feat(cli): include every pairing address in QR codes (#53588) 2026-10-06 16:13:34 -04:00
Dax 36a38058fb feat(core): stop asking for external directory access by default (#53585) 2026-10-06 15:52:23 -04:00
Dax ccb5162714 fix(tui): match execute dialog to two-toned dialog layout (#53584) 2026-10-06 15:20:21 -04:00
opencode-agent[bot]andrekram1-node c2f87853c6 fix(core): drop tool name and namespace length caps (#53586)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-10-06 14:15:40 -05:00
Shoubhit Dash ed0e7acecd fix(acp): reject a mismatched fork cwd and resolve overlapping provider IDs (#53534) 2026-10-06 22:49:59 +05:30
Adam de2cacdac8 fix(docs): redirect inference help link 2026-10-06 11:18:52 -05:00
opencode-agent[bot] 6bffe7932e chore(core): refresh bundled models.dev snapshot 2026-10-06 12:21:22 +00:00
Victor Navarro ba281e7f91 docs(console): add workspace, providers, and api docs (#53531) 2026-10-06 14:11:33 +02:00
Shoubhit Dash f17b15eaa5 fix(acp): tighten cwd, cancel, delete, and terminal auth handling (#53359) 2026-10-06 17:15:51 +05:30
Shoubhit Dash f6ae986549 fix(acp): report tool names and full-file diffs (#53360) 2026-10-06 17:12:27 +05:30
Shoubhit Dash fb72e25c06 fix(acp): wait for a cold location's plugins before the first catalog read (#53358) 2026-10-06 17:10:48 +05:30
opencode-agent[bot] 184890e429 chore: update nix node_modules hashes 2026-10-06 11:23:46 +00:00
opencode-agent[bot]andBrendonovich a7523ac4b7 fix(ui): render dollar math with marked-katex-extension (#53513)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 19:13:35 +08:00
Brendan Allan 80c4a6b7c8 feat(app): polish mobile session navigation and drawers (#53267) 2026-10-06 17:12:44 +08:00
David Hill 592daf29f4 fix(app): refine project selector menu (#52349) 2026-10-06 09:01:34 +00:00
Luke Parker b1629294ec feat(desktop): redesign the browser bar (#53488) 2026-10-06 19:00:29 +10:00
opencode-agent[bot]andBrendonovich 8eb2ebd96c fix(app): disable submit hover on touch (#49461)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 08:08:53 +00:00
opencode-agent[bot]andBrendonovich 2eff7da1b6 fix(app): allow touch scrolling in directory picker (#51781)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 08:07:35 +00:00
opencode-agent[bot]andBrendonovich da20865bb9 fix(app): use HTTP-safe UUID for restored comment notes (#53501)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 07:53:42 +00:00
opencode-agent[bot] 6e4de805bb chore: update nix node_modules hashes 2026-10-06 06:00:33 +00:00
Kit Langton 50ae4bd4db chore: remove unused dependencies from V2 packages (#53482) 2026-10-05 22:51:11 -07:00
Jack 89a8f1a3db docs(go): document paid Space Bunny (#53487) 2026-10-06 13:28:36 +08:00
Kit Langton 81b92c043f feat(core): enable portable shell scanner on local and dev channels (#53479) 2026-10-05 22:13:16 -07:00
DmitriiandAiden Cline cabb5d035b fix(core): migrate the v1 thinking block-binding opt-out (#52327)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-06 00:06:58 -05:00
Kit Langton 2bddf58716 refactor(client): share the registered service decision between clients (#53241) 2026-10-05 21:35:29 -07:00
Max CoplanandOpenCode 8f0f4be03c fix(script): normalize local build channels (#53461)
Co-authored-by: OpenCode (GPT-5.6-Sol) <noreply@opencode.ai>
2026-10-05 23:03:27 -05:00
b78d10cd71 fix(server): always report opencode as mcp client name (#53471)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Eric Curtin <eric.curtin@docker.com>
2026-10-05 22:20:45 -05:00
opencode-agent[bot]andBrendonovich cb11287ae6 fix(app): use HTTP-safe UUID for attachment uploads (#51783)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 03:08:32 +00:00
9daf46b327 fix(session-ui): align provider error icon with first line (#52859)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 13:07:18 +10:00
opencode bd55d4895f sync release versions for v2.0.24 [skip ci] 2026-10-06 03:04:19 +00:00
opencode-agent[bot]andrekram1-node 7c59e7bfee chore(tui): remove unused plugin install keybinds (#53470)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-05 21:50:18 -05:00
opencode-agent[bot] 32a67d2d0a chore: update nix node_modules hashes 2026-10-06 02:33:42 +00:00
Vladimir Glafirov a1d2085af3 chore(core): bump gitlab-ai-provider to 6.19.0 (#53352) 2026-10-05 21:22:20 -05:00
Aiden Cline d318cf4125 fix(core): rename legacy OpenAI OAuth methods to Codex (#53467) 2026-10-05 21:21:42 -05:00
Vladimir Glafirov 5ae8a3c1d3 fix(core): resolve reasoning variants for GitLab Duo models (#51082) 2026-10-05 21:12:37 -05:00
Aiden Cline 209346a0fe fix(core): temporarily stop syncing w/ /models api for sign in w/ chatgpt due to openai bugs (#53466) 2026-10-05 21:05:11 -05:00
opencode-agent[bot]andLukeParkerDev 3ca0ac718b fix(session-ui): space errors and grouped updates in timeline (#52876)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 00:38:15 +00:00
Aiden Cline 48b295aef4 fix(core): clarify legacy ChatGPT OAuth method labels (#53451) 2026-10-05 18:56:17 -05:00
Luke Parker 19f8610c0a fix(app): commit a staged revert before switching the selection (#53445) 2026-10-06 09:40:44 +10:00
usrnk1andLukeParkerDev 754b144263 feat(desktop): clarify queued and pending steer messages (#53338)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 09:29:55 +10:00
opencode-agent[bot] 394a4fc64b chore: update nix node_modules hashes 2026-10-05 23:16:55 +00:00
723 changed files with 21765 additions and 4276 deletions

No files matched your search

+84 -69
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.24",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -131,6 +131,7 @@
"@opencode/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentunnel/client": "0.2.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
@@ -179,7 +180,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -192,7 +193,6 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
@@ -205,7 +205,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -254,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -281,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -322,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/perplexity": "3.0.26",
@@ -369,13 +369,11 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.18.0",
"google-auth-library": "10.5.0",
"gitlab-ai-provider": "6.19.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
@@ -409,7 +407,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -454,7 +452,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -491,7 +489,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -507,8 +505,12 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@betteroffice/docx": "0.4.3",
"@betteroffice/fonts": "0.4.0",
"@betteroffice/pptx": "0.2.0",
"@betteroffice/xlsx": "0.3.0",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/solid": "0.5.0",
@@ -552,7 +554,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -571,7 +573,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -584,7 +586,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -598,7 +600,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -613,7 +615,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -621,9 +623,7 @@
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@opencode/theme": "workspace:*",
@@ -639,8 +639,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.14",
"@opentui/solid": ">=0.5.14",
"@opentui/core": ">=0.5.16",
"@opentui/solid": ">=0.5.16",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -652,7 +652,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -682,7 +682,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -697,7 +697,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -721,7 +721,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -742,7 +742,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -751,7 +751,6 @@
"@opencode/schema": "workspace:*",
"@opencode/simulation": "workspace:*",
"@opencode/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
@@ -764,7 +763,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -799,7 +798,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -819,7 +818,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -853,7 +852,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -872,7 +871,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -918,7 +917,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -932,7 +931,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -966,8 +965,9 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-listener": "catalog:",
@@ -975,6 +975,7 @@
"fuzzysort": "catalog:",
"katex": "0.16.47",
"marked": "catalog:",
"marked-katex-extension": "5.1.13",
"marked-shiki": "catalog:",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -1001,7 +1002,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1039,7 +1040,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1080,7 +1081,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1157,9 +1158,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.14",
"@opentui/keymap": "0.5.14",
"@opentui/solid": "0.5.14",
"@opentui/core": "0.5.16",
"@opentui/keymap": "0.5.16",
"@opentui/solid": "0.5.16",
"@pierre/diffs": "1.5.1",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -1639,6 +1640,14 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@betteroffice/docx": ["@betteroffice/docx@0.4.3", "", { "peerDependencies": { "@betteroffice/fonts": "^0" }, "optionalPeers": ["@betteroffice/fonts"] }, "sha512-CEErXJH3iCsi2a61gTNkAofLCzSTcktLmp710gGhMwbBfOqwQ2yI7dRFBV3ElGa9JxkS03IfejvLX0sAvlTrUQ=="],
"@betteroffice/fonts": ["@betteroffice/fonts@0.4.0", "", { "peerDependencies": { "@betteroffice/fonts-cjk": "^0" }, "optionalPeers": ["@betteroffice/fonts-cjk"] }, "sha512-x1BVwYDAgY5R6TWI4t9NLguSiAEbH5GItN2ZlpBIFoRXTIJgjBsXd6sUxPGpexXOyY6KdhRjG8XvHjyAYxEGCw=="],
"@betteroffice/pptx": ["@betteroffice/pptx@0.2.0", "", {}, "sha512-pNPQ58tlA+pVibERbLdQNskvP7a0GIMf4jhkCU/gPp8043Ichf6gB3PSb4ra9sfqoIaWqOaZKCWyptovA2+Q0A=="],
"@betteroffice/xlsx": ["@betteroffice/xlsx@0.3.0", "", {}, "sha512-K2bHtdk/epAxsmvxJlIWBRndjP3ltQU0Oh3p4gcZd2n5/YgXglo3SmMYX4nQe8WEm/yvz91p+aIasKyXXQP9Wg=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
@@ -2011,6 +2020,8 @@
"@jsx-email/text": ["@jsx-email/text@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-0zzwEwrKtY6tfjPJF0r3krKCDpP/ySYDvkn4+MvIFrIH5RZKmn3XDa5o/3hkbxMwpLn4MsXGIXn9XzMTaqTfUA=="],
"@kitlangton/solid-motion": ["@kitlangton/solid-motion@0.2.2", "", { "dependencies": { "motion-dom": "14.0.0", "motion-utils": "14.0.0" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-Fp7T8gJta3aRGJakBPQ6WIWmF6U6TmWhT6sGNNgkBiJ8cOVfuKkF9yYXzhvgRtKpo7WfzxtNLku907dLHhTDtw=="],
"@kobalte/core": ["@kobalte/core@0.13.13", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.2", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.2.0", "solid-prevent-scroll": "^0.1.11" }, "peerDependencies": { "solid-js": "^1.9.8" } }, "sha512-czBC+IQdOgJoW7DJjeh0rht7SPmTPgWsKg7Jo3RHMfwx028WxJtDylx9dJuJuD/GUae15+E4nYafu8LwuixFYQ=="],
"@kobalte/utils": ["@kobalte/utils@0.9.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow=="],
@@ -2295,27 +2306,31 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
"@opentui/core": ["@opentui/core@0.5.16", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.16", "@opentui/core-darwin-x64": "0.5.16", "@opentui/core-linux-arm64": "0.5.16", "@opentui/core-linux-arm64-musl": "0.5.16", "@opentui/core-linux-x64": "0.5.16", "@opentui/core-linux-x64-musl": "0.5.16", "@opentui/core-win32-arm64": "0.5.16", "@opentui/core-win32-x64": "0.5.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-RDVip7Ih8Lg5tFkwTDGFqc0TXx8A+rRvm3UuDAqd9YayDx4fIsECBjSS8FDPLYvq5KP1gJB4oY8iGM8WgTbNqw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mbdp/A2KJOv7hI0b0ezqZx/5vh5G6LwhZnjRW4K3hNcoZM4yC55y/D+L8J960rT4o7QRB3cNXr3NSlnVi1btZA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-D1oA0VBitDHOYrg58GaxQQNAXJ1nSFwu2HQMZklL/LkrqZLX23K/oxgutH/GKvLsyRQNsIVfsXmGZ2LtGiGpxA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-ghu4tKIR3wZMZ8Ih/VLeyyG/C7kQgAZuKoGKRSUPcVEF6Qr99yHhtIixBrBoFXfqVm63n5lGimS6KKqbdUQ1yA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1vppHpf6iR7k+JF6kQtcLKr11gIxm2nH1h9y3eawrG4NCXDQbOvn7XtC6KsT3I1iy0A2Yy+nM0heISziORZlQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.16", "", { "os": "linux", "cpu": "x64" }, "sha512-y63M7JrjfAQPUwTXogDGqBjS46DfC51LebBUQYVE01/N2F1inybD08qEfxUAFx/Omlf7CXef6liLDdRyTLaohw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.16", "", { "os": "linux", "cpu": "x64" }, "sha512-lQ1kRwFPBRElG2uFl0afWzpHyUwVEizbV2Ue1IA8DXH15sRCOd7KqeOltpzuKJYIyLey7vLF8SofAe8K1JNnTg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-cML9+1tqEe+SaB5xlx9jKcYAIG15Eg9sImH/LYyMbm+al7dRBNy5IzdFCldXfwC50blM/goiyzLN1CjTo02vCQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1QZ9yHK9xTZBNXVgjPfbdQ94nfw0/KZic9XkuAsNBzUGTkYmfGKZSvPB1YKomBt/1lipRoCdMAnihlS/R7g5eg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.14", "", { "dependencies": { "@opentui/core": "0.5.14" }, "peerDependencies": { "@opentui/react": "0.5.14", "@opentui/solid": "0.5.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YGTAvRrpQTbRNV7GH0UxRuCSPxwnSooVdB+qPHHP+3ywc93+lhekljgP8dRivl32f3CiMqqm96Uj2PgwKxkC0Q=="],
"@opentui/keymap": ["@opentui/keymap@0.5.16", "", { "dependencies": { "@opentui/core": "0.5.16" }, "peerDependencies": { "@opentui/react": "0.5.16", "@opentui/solid": "0.5.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-W78sDuTHMjHa8NwYmoGx5Xy0ztcGsuzrUa/1d24jADj9JCYEJQjABnrWZBRSExsNjiFYV3SxeCKwAPhJli0M3g=="],
"@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
"@opentui/solid": ["@opentui/solid@0.5.16", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gCULWG+HORd5IVydiCchlmULgY1PQccODJvJKDNYJHDmhwyg9/sNSFUxoYajEXLZ40pUIL8ENAK+6p34Z9m/JQ=="],
"@opentunnel/client": ["@opentunnel/client@0.2.0", "", { "dependencies": { "@opentunnel/protocol": "0.2.0", "effect": "4.0.0-rc.112" } }, "sha512-UHbLK+y2aPdcTuu4FaCz3Ezcm52Z4TxGp5Hdy9aLYLsz9GUlPpPFaYXjdq8pFyfQlA9qAyI/z5zx5FlZx3aSbw=="],
"@opentunnel/protocol": ["@opentunnel/protocol@0.2.0", "", { "dependencies": { "effect": "4.0.0-rc.112" } }, "sha512-XQAm3InG70HysML+NiSPxCCfoP+ZzWuxXcCFFFh/qxrvMxuJyOrxsPT49vTo2rDCgrYYdbiFqpz8oXIJh0BYXw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -4153,7 +4168,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.18.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-dXTXkNt1SFCL7jGlqazHL6iUJEug21qq0tx2s+Tui7jWNUqIAHEzY9WY1+PnZlhxvB56jPPBcFAT0attpjnopg=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.19.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-ym+p6PxKvCKfBE3VKYTiacJwsmgq/DrOuGctpBnbIBekdnZT31RM6yTTNdzVS83K3+Ym4M+qNY34HEcTVM6EEQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4299,7 +4314,7 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
@@ -4613,6 +4628,8 @@
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"marked-katex-extension": ["marked-katex-extension@5.1.13", "", { "peerDependencies": { "katex": ">=0.16 <0.19", "marked": ">=4 <19" } }, "sha512-+6gVygGg5at9g+YsuIQZk7XcAc6Pg1P0grEJKEWvF7DAwceZ7ZIvU0fhQe6auUqSbnPmSjJxZuICdSCdbGaFRw=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -4781,9 +4798,9 @@
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion-dom": ["motion-dom@14.0.0", "", { "dependencies": { "motion-utils": "14.0.0" } }, "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"motion-utils": ["motion-utils@14.0.0", "", {}, "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -6283,8 +6300,6 @@
"@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=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6517,8 +6532,6 @@
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -7177,8 +7190,6 @@
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -7459,6 +7470,10 @@
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
[test]
root = "./do-not-run-tests-from-root"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4AqU8dPEwo0ukoyEX1SuDVzsXQ7G/fIPnxoL400zH4M=",
"aarch64-linux": "sha256-p75sJYtR8a4oVKb1+Dgp8Kl+1onSmUw5NRv95bbV1+E=",
"aarch64-darwin": "sha256-Psyvbw/lGulysQlhK/MqcHVIwx2aCDTr+Pfr+gRWsG4="
"x86_64-linux": "sha256-m8XFudp6AwHWDZO7rDXCTkNF0XzgdnWUAg0VCgmGoDQ=",
"aarch64-linux": "sha256-CQmS4coITqsMmtgi/g4Xivy/d6sDIklAE8duCUkAazs=",
"aarch64-darwin": "sha256-fSeacvDBLUCSKIeGCTgZsmqfo2rohARHvFgYLaNXHi8="
}
}
+4 -4
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.23",
"version": "2.0.24",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -53,9 +53,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.14",
"@opentui/keymap": "0.5.14",
"@opentui/solid": "0.5.14",
"@opentui/core": "0.5.16",
"@opentui/keymap": "0.5.16",
"@opentui/solid": "0.5.16",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.24",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -285,7 +285,7 @@ const AnthropicThinkingEnabled = Schema.Struct({
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
type AnthropicThinking = typeof AnthropicThinking.Type
@@ -1001,18 +1001,21 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
// only reads `request.model.compatibility`.
const isThinkingOff = Schema.is(AnthropicThinkingDisabled)
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
const supportsEffortUpdates = (request: LLMRequest) => {
if (isThinkingOff(request.providerOptions?.thinking)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
const version = claudeVersion(request.model.id)
if (version === undefined) return false
if (version.family === "opus" && version.major >= 5) return true
return version.major > 5 || (version.major === 5 && version.minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
if (thinking?.type === "disabled") return thinking
if (isThinkingOff(thinking)) return thinking
if (!supportsThinkingBlockBinding(model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
@@ -1607,7 +1610,7 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
supportsEffortUpdates,
})
export const transport = <
@@ -1653,7 +1656,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
if (thinking && !isThinkingOff(thinking) && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
return betas
}
@@ -476,7 +476,9 @@ const MIN_THINKING_BUDGET = 1_024
const isThinkingDisabled = Schema.is(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
additionalModelRequestFields: Schema.Struct({
thinking: Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) }),
}),
}),
)
@@ -33,7 +33,7 @@ const Options = Schema.Struct({
store: lenient(Schema.Boolean),
thinkingLevel: lenient(ThinkingLevel),
thinkingSummaries: lenient(knownString<"auto" | "none">()),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
serviceTier: lenient(knownString<"standard" | "flex" | "priority" | "deferred">()),
})
export type OptionsInput = typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
@@ -507,6 +507,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
if (part.text.length === 0 && !encryptedContent) return undefined
return {
type: "reasoning",
...(id === undefined ? {} : { id }),
@@ -37,7 +37,8 @@ export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Creden
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
// ignoreCache re-reads shared config and SSO token files, so `aws sso login` takes effect without a restart.
const identity = await fromNodeProviderChain({ ignoreCache: true, profile: options.profile })()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
@@ -9,7 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
// Mirrors OpenAI's `ServiceTier` union from the official SDK.
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale", "fast", "ultrafast"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
@@ -65,8 +65,8 @@ const messagesRoute = Route.make({
...AnthropicMessages.protocol,
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
supportsEffortUpdates: (request) => {
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
if (!(AnthropicMessages.protocol.supportsEffortUpdates?.(request) ?? false)) return false
if (request.model.compatibility?.supportsEffortUpdates !== undefined) return true
const version = claudeVersion(request.model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
},
+16
View File
@@ -268,6 +268,22 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers when thinking is disabled or between_tools", () =>
Effect.gen(function* () {
const sonnet = anthropic("claude-sonnet-5-5")
const betweenTools = yield* compileRequest(
LLM.request({
model: sonnet,
messages: conversation,
providerOptions: { thinking: { type: "between_tools" }, effort: "low" },
}),
)
expect(systemMessages(betweenTools.body)).toHaveLength(0)
expect(betweenTools.body.output_config).toEqual({ effort: "low" })
}),
)
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
Effect.gen(function* () {
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
@@ -9,6 +9,7 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "ultrafast" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
@@ -44,6 +44,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
Effect.gen(function* () {
for (const thinking of [
{ type: "disabled" },
{ type: "between_tools" },
{ type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } },
] as const) {
const request = LLM.request({
@@ -55,7 +56,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled"
thinking.type === "disabled" || thinking.type === "between_tools"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
@@ -325,11 +325,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through provider-defined service tiers", () =>
it.effect("passes through provider-defined and future service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
for (const serviceTier of ["scale", "ultrafast", "future-tier"]) {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier } }))
expect(prepared.body.service_tier).toBe("scale")
expect(prepared.body.service_tier).toBe(serviceTier)
}
}),
)
@@ -4011,6 +4013,46 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops empty reasoning items without summary or encrypted content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_empty_shell",
model,
messages: [
Message.user("What changed?"),
Message.assistant([
{
type: "reasoning",
text: "",
providerMetadata: {
openai: {
itemId: "rs_6aa28a10c05cf9f566f44022:rs_01a08aeb51217b92a5b853a0cb5b20ca",
reasoningEncryptedContent: null,
},
},
},
{ type: "text", text: "The parser changed." },
]),
Message.user("Summarize it."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "The parser changed." }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
])
}),
)
it.effect("assembles streamed function call input", () =>
Effect.gen(function* () {
const body = sseEvents(
+178 -18
View File
@@ -227,17 +227,17 @@ story("keeps the comment editor and its actions inside the page", async ({ page
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByText("Preview your app, read docs, or browse the web.", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
@@ -245,9 +245,9 @@ story("shows the empty state over a blank native page and restores navigation",
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -255,12 +255,12 @@ story("keeps the submitted URL visible until the browser reports navigation", as
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
@@ -274,7 +274,7 @@ story("keeps the submitted URL visible until the browser reports navigation", as
story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
for (const submission of [1, 2]) {
await story.step(`blocked submission ${submission}`, async () => {
@@ -282,13 +282,28 @@ story("restores the current URL each time the same submitted navigation is block
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByRole("alert")).toHaveText("Request failed")
await expect(root.getByRole("alert")).toHaveText("Only web pages can open here.")
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
}
})
story("explains why a typed address cannot open instead of searching for it", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
for (const [typed, reason] of [
["ftp://example.com", "Only web pages can open here."],
["https://user:pass@example.com", "Addresses with a user name or password can't open here."],
]) {
await address.fill(typed)
await address.press("Enter")
await expect(root.getByRole("alert")).toHaveText(reason)
await expect(address).toHaveValue("https://alpha.example/")
}
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
@@ -296,7 +311,7 @@ story("shows a themed failure state for only the failed tab and allows retry", a
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
await expect(root.getByRole("combobox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
@@ -317,7 +332,7 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
@@ -326,14 +341,14 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
@@ -352,10 +367,55 @@ story("selects the full URL when the address field gains focus", async ({ page }
.toBe(true)
})
story("draws only the address at rest when focus leaves the window with the field selected", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
// Chromium blurs the field when the window or the native page takes focus, and the field keeps its full selection.
await address.evaluate((input: HTMLInputElement) => {
input.dispatchEvent(new FocusEvent("blur"))
input.select()
})
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await page.mouse.move(0, 0)
// Computed ::selection styles do not report the default highlight, so compare the input's own paint with the
// selection collapsed. The drawn address is hidden: a selection layer can change how its text is antialiased.
await page.addStyleTag({ content: '[data-slot="browser-address-display"] { visibility: hidden }' })
const field = root.locator('[data-component="browser-address"]')
const selected = (await field.screenshot()).toString("base64")
await address.evaluate((input: HTMLInputElement) => input.setSelectionRange(0, 0))
const collapsed = (await field.screenshot()).toString("base64")
const changed = await page.evaluate(
async (shots) => {
const pixels = await Promise.all(
shots.map(async (data) => {
const image = new Image()
image.src = `data:image/png;base64,${data}`
await image.decode()
const canvas = new OffscreenCanvas(image.width, image.height)
canvas.getContext("2d")?.drawImage(image, 0, 0)
return canvas.getContext("2d")?.getImageData(0, 0, image.width, image.height).data ?? new Uint8ClampedArray()
}),
)
return Array.from({ length: (pixels[0]?.length ?? 0) / 4 }, (_, index) => index * 4).filter((offset) =>
[0, 1, 2].some((channel) => pixels[0]?.[offset + channel] !== pixels[1]?.[offset + channel]),
).length
},
[selected, collapsed],
)
expect(changed).toBe(0)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
@@ -371,12 +431,112 @@ story("keeps the current page visible while a submitted URL loads", async ({ pag
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("suggests visited pages and opens the first one that completes the typed address", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Seed history", exact: true }).click()
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
await address.pressSequentially("localhost")
const options = page.getByRole("option")
await expect(options).toHaveCount(1)
await expect(options.first()).toHaveAttribute("aria-selected", "true")
// Arrow keys that pick IME candidates leave the suggestions alone.
await address.dispatchEvent("keydown", { key: "ArrowDown", isComposing: true })
await expect(options.first()).toHaveAttribute("aria-selected", "true")
await address.press("Enter")
await expect(address).toHaveValue("http://localhost:5173/settings")
// A search query also opens the first match; stepping past the list leaves the typed text, which searches.
await address.click()
await address.pressSequentially("color format")
await expect(options).toHaveCount(1)
await expect(options.first()).toHaveAttribute("aria-selected", "true")
await address.press("ArrowDown")
await expect(options.first()).toHaveAttribute("aria-selected", "false")
await address.press("Enter")
await expect(address).toHaveValue("https://www.google.com/search?q=color%20format")
})
story("lists recent pages on a blank tab and opens one", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await root.getByRole("button", { name: "Seed history", exact: true }).click()
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Recent", { exact: true })).toBeVisible()
// The globe at the field's start belongs to the field: a click there focuses it.
await expect(address).not.toBeFocused()
await root.locator('[data-component="browser-address"]').click({ position: { x: 16, y: 14 } })
await expect(address).toBeFocused()
await address.blur()
await root.getByRole("button", { name: /^Settings - Preview/ }).click()
await expect(address).toHaveValue("http://localhost:5173/settings")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
// The opened page owns the field afterwards: an edit that is never submitted shows its URL again.
await address.click()
await address.fill("unsubmitted")
await address.blur()
await expect(address).toHaveValue("http://localhost:5173/settings")
})
story("adds the whole page to the session and keeps the draft when the button is pressed again", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const field = root.locator('[data-component="browser-address"]')
const add = root.getByRole("button", { name: "Add page to session", exact: true })
await field.hover()
await add.click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await editor.fill("Check the header layout")
await field.hover()
await add.click()
await expect(editor).toHaveValue("Check the header layout")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([/: Check the header layout$/])
})
story("focuses the address field when the page uses the address shortcut", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Address shortcut", exact: true }).click()
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await expect(address).toBeFocused()
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
})
story("shows the page's cookies in the site information and clears them", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Site information", exact: true }).click()
const site = page.getByRole("dialog")
await expect(site).toContainText("3 cookies in use")
await site.getByRole("button", { name: "Clear", exact: true }).click()
await expect(site).toContainText("0 cookies in use")
})
story("zooms the page from the browser options in view and keeps its zoom in the address field", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const options = root.getByRole("button", { name: "Browser options", exact: true })
await options.click()
await page.getByRole("menuitem", { name: /^Zoom in/ }).click()
await expect(root.getByText("Zoom: 110", { exact: true })).toBeVisible()
// The menu covered the page with a still; it closes so the zoomed page shows at once.
await expect(page.getByRole("menu")).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
const zoom = root.getByRole("button", { name: "110%", exact: true })
await expect(zoom).toBeVisible()
await options.click()
await expect(page.getByRole("menuitem", { name: /^Reset zoom/ })).toContainText("110%")
await page.keyboard.press("Escape")
await zoom.click()
await expect(root.getByText("Zoom: 100", { exact: true })).toBeVisible()
await expect(zoom).toBeHidden()
})
@@ -95,7 +95,14 @@ for (const direction of ["ltr", "rtl"]) {
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
// Below the mobile breakpoint the variant picker opens in a bottom drawer instead of a menu.
await expect(
width < 768
? page
.getByRole("dialog", { name: "Choose model variant", exact: true })
.getByRole("button", { name: "high", exact: true })
: page.getByRole("menuitemradio", { name: "high", exact: true }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
@@ -85,7 +85,7 @@ const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [] }
const servers: Servers = { list: () => [], get: () => undefined }
const workspaces: Workspaces = { on: () => () => undefined }
@@ -348,6 +348,7 @@ function standIn(overrides: Partial<Interface>): Interface {
keybind: () => [],
matches: () => false,
servers: () => ["local"],
server: () => undefined,
...overrides,
}
}
@@ -38,6 +38,16 @@ test("home commands do not wait for session search", async ({ page }) => {
release.resolve()
})
test("lists recent sessions before a query is entered", async ({ page }) => {
const { dialog } = await openCommandPalette(page, true)
const session = dialog.getByRole("option", { name: /Palette fixture session/ })
await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible()
await expect(session).toHaveAttribute("aria-selected", "false")
await session.click()
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
@@ -213,7 +213,18 @@ test("keeps a narrow session composer contained when invoking a built-in", async
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
const dialog = page.getByRole("dialog", { name: "Select model", exact: true })
const search = dialog.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).toBeFocused()
await expect(editor).toHaveText("keep me")
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await editor.fill("/model")
await expect(page.locator('[data-suggestion-id="model.choose"]')).toHaveAttribute("data-active", "")
await editor.press("Enter")
await expect(search).toBeFocused()
await expect(editor).toBeEmpty()
})
test("lists slash commands in their built-in order", async ({ page }) => {
+81 -1
View File
@@ -1,6 +1,8 @@
import { expect, test, type Page } from "@playwright/test"
import { holdRoute, seed, sessionHref } from "../utils/app"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { expectAppVisible } from "../utils/waits"
test.use({ serviceWorkers: "block" })
@@ -85,8 +87,11 @@ test("Home shows loaded sessions before the location request resolves", async ({
test("Home and the directory picker load without newer browser APIs", async ({ page }) => {
await page.addInitScript(() => {
// Safari 16.6 has none of these APIs. Remove them before the web entry runs.
// SAFETY: `Partial` only makes the static method optional so `delete` type-checks; the target is the real global.
delete (Map as Partial<typeof Map>).groupBy
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).withResolvers
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).try
})
await openHome(page, { fileList: () => [] })
@@ -101,6 +106,81 @@ test("Home and the directory picker load without newer browser APIs", async ({ p
await expect(target).toBeVisible()
})
test("adding a project to a signed-out server re-pairs it, then continues at the paired address", async ({ page }) => {
// The saved address rejects the new token, so saving moves the server to the link's address.
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { name: "Remote", password: "old-password" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
await remote.getByRole("button", { name: "Add project", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(`${paired}/auth/connect/one-time-code`)
await editor.getByRole("button", { name: "Save", exact: true }).click()
// The folder picker reads the paired address; the removed one would reject the request.
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
test("a server whose saved password changes works with the new password", async ({ page }) => {
const accepted = { password: "old-password" }
await mockOpenCodeServer(page, {
server: REMOTE_SERVER,
directory: "/remote/project",
project: project({ id: "proj_remote", directory: "/remote/project" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: () => accepted.password,
})
// Saved with the password the server accepts until the test changes it.
await seed(page, {
storage: {
"opencode.global.dat:server": {
list: [{ type: "http", displayName: "Remote", http: { url: REMOTE_SERVER, password: "old-password" } }],
},
},
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
// Use the server once, so its controller exists with the old password.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(picker).toHaveCount(0)
await remote.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Edit", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByPlaceholder("password").fill("new-password")
// The server now rejects the old password, as after `opencode service set password`.
accepted.password = "new-password"
await editor.getByRole("button", { name: "Save", exact: true }).click()
await expect(editor).toHaveCount(0)
// The picker reads the folder through the server's controller, which must use the saved password now.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
const recovery = "C:/OpenCode/Worktrees/project-menu-recovery"
const worktree =
@@ -0,0 +1,79 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const { width, direction } of [
{ width: 390, direction: "ltr" },
{ width: 390, direction: "rtl" },
{ width: 1280, direction: "ltr" },
] as const) {
test(`submitting a prompt ${width < 768 ? "unfocuses" : "keeps focus on"} the composer at ${width}px in ${direction}`, async ({
page,
}) => {
await openSubmitSession(page, width, direction)
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please check focus after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await editor.press("Enter")
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
if (width < 768) {
await expect(editor).not.toBeFocused()
return
}
await expect(editor).toBeFocused()
})
}
test.describe("touch submission", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("tapping Send unfocuses the composer", async ({ page }) => {
await openSubmitSession(page, 390, "ltr")
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please dismiss the keyboard after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await page.getByRole("button", { name: "Send", exact: true }).tap()
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
await expect(editor).not.toBeFocused()
})
})
async function openSubmitSession(page: Page, width: number, direction: "ltr" | "rtl") {
await page.setViewportSize({ width, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
}
@@ -0,0 +1,208 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile extensions reuse the session drawer in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
for (const [name, empty] of [
["MCP", "No MCP servers configured"],
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers configured"],
]) {
const trigger = details.getByRole("button", { name, exact: true })
await trigger.click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(empty, { exact: true })).toBeVisible()
await expect(extension).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(extension.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(extension.locator(".session-service-menu")).toHaveCount(0)
await expect(extension).toBeInViewport({ ratio: 1 })
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(trigger).toBeFocused()
}
await details.getByRole("button", { name: "Skills", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Skills", exact: true }).getByText("No skills configured", { exact: true }),
).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("extensions.png") })
await testInfo.attach("extensions", { path: testInfo.outputPath("extensions.png"), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(more).toBeFocused()
await more.click()
await expect(
page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true }),
).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("mobile extension drawers keep configured lists, retry, refresh, and MCP toggles working", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
const state = { fail: true, extra: false, enabled: true }
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{
id: "drawer-plugin",
source: { type: "package", target: "drawer-plugin" },
features: {},
state: { status: "active" },
},
...(state.extra
? [
{
id: "extra-plugin",
source: { type: "package", target: "extra-plugin" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "drawer-skill", name: "drawer-skill", path: "/skills/drawer/SKILL.md", content: "Drawer skill" },
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({ json: [{ type: "document", info: { lsp: { "drawer-lsp": { command: ["drawer-lsp"] } } } }] }),
)
await page.route(
(url) => url.pathname.startsWith("/api/mcp") || url.pathname.startsWith("/api/experimental/mcp"),
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const path = new URL(route.request().url()).pathname
if (path.endsWith("/disconnect")) {
state.enabled = false
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
path === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `mcp-${String(index).padStart(2, "0")}`,
status: { status: index === 0 && !state.enabled ? "disabled" : "connected" },
})),
},
})
},
)
await openWithDirection(page, sessionHref(fixture.targetID), "ltr")
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await details.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("drawer-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
state.extra = true
await details.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("extra-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
for (const [name, item] of [
["Skills", "drawer-skill"],
["LSP", "drawer-lsp"],
]) {
await details.getByRole("button", { name, exact: true }).click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(item, { exact: true })).toBeVisible()
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
}
await details.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(mcp.getByRole("switch")).toHaveCount(30)
await expect
.poll(() =>
mcp
.locator('[data-slot="mobile-panel-content"]')
.evaluate((element) => element.scrollHeight > element.clientHeight),
)
.toBe(true)
await expect(mcp).toBeInViewport({ ratio: 1 })
const disconnect = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/disconnect"))
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).focus()
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).press("Space")
await disconnect
await expect(mcp.getByRole("switch", { name: "mcp-00", exact: true })).not.toBeChecked()
const last = mcp
.locator(".session-mcp-row")
.filter({ has: page.getByRole("switch", { name: "mcp-29", exact: true }) })
await last.scrollIntoViewIfNeeded()
await expect(last).toBeInViewport()
await mcp.getByRole("switch", { name: "mcp-29", exact: true }).focus()
await expect(mcp.getByRole("switch", { name: "mcp-29", exact: true })).toBeFocused()
})
@@ -1,10 +1,21 @@
import { expect, test, type Request } from "@playwright/test"
import { expect, test, type Page, type Request } from "@playwright/test"
import { sessionHref } from "../utils/app"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { fileNode } from "../utils/workspace"
test.use({ viewport: { width: 390, height: 844 } })
async function openFiles(page: Page) {
await page
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true })
.click()
}
test("mobile files browse, comment, search, and close tabs", async ({ page }) => {
await mockStressTimeline(page, {
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
@@ -14,7 +25,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
})
await page.goto(sessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
@@ -50,7 +61,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
.click()
await expect(session).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
await expect(filter).toBeHidden()
@@ -63,7 +74,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
@@ -239,7 +250,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(navigation.getByRole("tab", { name: "More...", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(selected).toHaveText([file])
await expect(files).toHaveAttribute("data-browsing", "false")
@@ -248,20 +259,23 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
}
})
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses by backdrop, Escape, and drag", async ({ page }) => {
await mockStressTimeline(page)
await page.goto(sessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag"] as const) {
for (const dismissal of ["backdrop", "escape", "drag"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
@@ -270,8 +284,6 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
@@ -0,0 +1,224 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
for (const direction of ["ltr", "rtl"] as const) {
for (const paid of [true, false]) {
test(`mobile model and variant pickers use drawers with ${paid ? "paid" : "free"} models in ${direction}`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await openPickerSession(page, direction, paid)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await expect(variant).toHaveText("balanced")
await variant.click()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await expect(variants.getByRole("button", { name: "balanced", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
await expect(variants.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(variants).toBeInViewport({ ratio: 1 })
await expect(variants).not.toHaveAttribute("data-transitioning")
await variants.screenshot({ path: testInfo.outputPath("variants.png") })
await page.keyboard.press("Escape")
await expect(variants).toBeHidden()
await expect(variant).toBeFocused()
await variant.click()
await variants.getByRole("button", { name: "high", exact: true }).click()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("high")
await expect(variant).toBeFocused()
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveText("Mobile Model A")
await expect(model).toHaveAttribute("data-control-type", "drawer")
await model.click()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(search).not.toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveAttribute("aria-pressed", "true")
await expect(models.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(models).toBeInViewport({ ratio: 1 })
await expect(models).not.toHaveAttribute("data-transitioning")
await expect
.poll(() =>
models.evaluate((element) => {
const drawer = element.getBoundingClientRect()
const search = element.querySelector('[data-component="text-input-v2"]')!.getBoundingClientRect()
const row = element.querySelector('[data-component="settings-row"]')!.getBoundingClientRect()
const searchClip = element.querySelector('[data-slot="mobile-panel-content"]')!.getBoundingClientRect()
const rowClip = element.querySelector(".settings-models")!.getBoundingClientRect()
return {
searchStart: Math.round(search.left - drawer.left),
searchEnd: Math.round(drawer.right - search.right),
rowStart: Math.round(row.left - drawer.left),
rowEnd: Math.round(drawer.right - row.right),
searchClipStart: Math.round(search.left - searchClip.left),
searchClipEnd: Math.round(searchClip.right - search.right),
rowClipStart: Math.round(row.left - rowClip.left),
rowClipEnd: Math.round(rowClip.right - row.right),
}
}),
)
.toEqual({
searchStart: 12,
searchEnd: 12,
rowStart: 12,
rowEnd: 12,
searchClipStart: 12,
searchClipEnd: 12,
rowClipStart: 12,
rowClipEnd: 12,
})
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-inline-start", "12px")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-block-start", "16px")
await models.screenshot({ path: testInfo.outputPath("models.png") })
await search.click()
await expect(search).toBeFocused()
await search.fill("no-matching-model")
await expect(models.getByText("No model results", { exact: true })).toBeVisible()
await search.fill("Mobile Model B")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCount(0)
await search.press("Enter")
await expect(models).toBeHidden()
await expect(model).toHaveText("Mobile Model B")
await expect(composer.getByRole("textbox", { name: "Prompt", exact: true })).toBeFocused()
await model.click()
await expect(search).toHaveValue("")
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model B/ })).toHaveAttribute("aria-pressed", "true")
await expect
.poll(() => models.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(models).not.toHaveAttribute("data-transitioning")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(models).toBeHidden()
await expect(model).toBeFocused()
if (paid) {
await model.click()
await models.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(models).toBeHidden()
await expect(page.getByRole("dialog", { name: "Manage models", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
return
}
await model.click()
await models.getByRole("button", { name: "Connect provider", exact: true }).click()
await expect(models).toBeHidden()
const providers = page.getByRole("dialog", { name: "Connect provider", exact: true })
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
})
}
test(`desktop model and variant pickers retain menus in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await openPickerSession(page, direction, true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.click()
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
await expect(variant).toHaveText("high")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveAttribute("data-control-type", "popover")
await model.click()
await expect(page.getByPlaceholder("Search models", { exact: true })).toBeFocused()
await page.getByRole("menuitemradio", { name: /Mobile Model B/ }).click()
await expect(model).toHaveText("Mobile Model B")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
})
}
test.describe("touch composer pickers", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("model and default variant open with one tap", async ({ page }) => {
await openPickerSession(page, "ltr", true)
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.tap()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await variants.getByRole("button", { name: "default", exact: true }).tap()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("default")
await page.touchscreen.tap(5, 300)
await expect(variant).toHaveCSS("pointer-events", "auto")
await expect(variant).toHaveCSS("opacity", "1")
await variant.tap()
await expect(variants).toBeVisible()
await expect
.poll(() => variants.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await page.locator('[data-slot="mobile-drawer-overlay"]').tap({ position: { x: 10, y: 10 } })
await expect(variants).toBeHidden()
await composer.locator('[data-action="composer-model"]').tap()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).not.toBeFocused()
await search.tap()
await expect(search).toBeFocused()
})
})
async function openPickerSession(page: Page, direction: "ltr" | "rtl", paid: boolean) {
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) => ({
...session,
model: { id: "mobile-a", providerID: "opencode", variant: "balanced" },
})),
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(
["a", "b"].map((id) => [
`mobile-${id}`,
{
id: `mobile-${id}`,
name: `Mobile Model ${id.toUpperCase()}`,
cost: { input: paid ? 1 : 0, output: paid ? 1 : 0 },
limit: { context: 200_000 },
variants: { balanced: {}, high: {} },
},
]),
),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "mobile-a" },
},
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem("opencode-color-scheme", direction === "rtl" ? "dark" : "light")
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
user: ["a", "b"].map((id) => ({ providerID: "opencode", modelID: `mobile-${id}`, visibility: "show" })),
recent: [],
variant: {},
}),
)
}, direction)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
await expectAppVisible(page.getByRole("textbox", { name: "Prompt", exact: true }))
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile session options are compact and readable in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
const drawer = page.getByRole("dialog", { name: "More options", exact: true })
await expect(drawer).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(drawer.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(drawer.getByRole("heading", { name: "More options", exact: true })).toHaveCSS("width", "1px")
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme)
await expect
.poll(() =>
drawer.evaluate((element) => {
const options = element.querySelector('[data-slot="session-mobile-view-options"]')!
return getComputedStyle(options).backgroundColor !== getComputedStyle(element).backgroundColor
}),
)
.toBe(true)
await drawer.screenshot({ path: testInfo.outputPath("options.png") })
await testInfo.attach("options", { path: testInfo.outputPath("options.png"), contentType: "image/png" })
for (const name of ["Files", "Terminal", "Usage", "Session details"]) {
const option = drawer.getByRole("button", { name, exact: true })
await expect(option).toHaveCSS("height", "44px")
await expect
.poll(() =>
option.evaluate((element) => {
const context = document.createElement("canvas").getContext("2d")!
function luminance(color: string) {
context.clearRect(0, 0, 1, 1)
context.fillStyle = color
context.fillRect(0, 0, 1, 1)
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((channel) => {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
}
const text = luminance(getComputedStyle(element).color)
const surface = luminance(getComputedStyle(element.parentElement!).backgroundColor)
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05)
}),
)
.toBeGreaterThanOrEqual(4.5)
}
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
for (const dismissal of ["backdrop", "drag"] as const) {
await more.click()
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "backdrop")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
if (dismissal === "drag") {
const handle = await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox()
expect(handle).not.toBeNull()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2)
await page.mouse.down()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2 + 1)
await page.mouse.move(handle!.x + handle!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
}
})
}
}
@@ -28,7 +28,8 @@ for (const position of ["top", "bottom"] as const) {
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
@@ -37,32 +38,39 @@ for (const position of ["top", "bottom"] as const) {
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(navigation).toHaveCount(1)
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "More..."])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
if (position === "top") {
const titlebar = page.locator('[data-slot="titlebar-v2"]')
await expect(titlebar).toHaveCSS("padding-top", "16px")
await expect(titlebar).toHaveCSS("height", "44px")
await expect(titlebar).toHaveCSS("padding-top", "8px")
await expect(titlebar).toHaveCSS("height", "36px")
}
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
return !!bounds && bounds.x >= 1 && bounds.x <= 2 && bounds.width >= 386 && bounds.width <= 388
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const dock = await page.locator('[data-component="session-composer-dock"]').boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
if (!bar || !input || !dock || !panel) return false
if (position === "bottom")
return bar.y >= dock.y + dock.height && Math.abs(bar.y + bar.height - panel.y - panel.height) <= 1
return Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
@@ -82,8 +90,21 @@ for (const position of ["top", "bottom"] as const) {
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(views).toBeVisible()
await expect(more).toHaveAttribute("aria-expanded", "true")
for (const name of ["Files", "Terminal", "Usage", "Session details"])
await expect(views.getByRole("button", { name, exact: true })).toBeVisible()
await expect(views).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(views).toBeHidden()
await expect(more).toHaveAttribute("aria-expanded", "false")
await expect(more).toBeFocused()
await expect(picker).toHaveText("Session")
await more.press("Enter")
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
@@ -92,24 +113,32 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(views.getByRole("button", { name: "Usage", exact: true })).toHaveAttribute("aria-pressed", "true")
await expect(views.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await views.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
await views.getByRole("button", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px")
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
@@ -118,18 +147,23 @@ for (const position of ["top", "bottom"] as const) {
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
await more.click()
await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true")
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
@@ -146,14 +180,14 @@ for (const position of ["top", "bottom"] as const) {
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
@@ -174,18 +208,20 @@ for (const position of ["top", "bottom"] as const) {
}
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTab(fixture.expected.targetTitle)
await expect(picker).toHaveText("Session")
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTabs()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
@@ -220,15 +256,15 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.setViewportSize({ width: 390, height: 844 })
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
await more.click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
).toHaveAttribute("aria-selected", "true")
@@ -244,7 +280,8 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.reload()
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 390, height: 844 })
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "README.md", exact: true }).click()
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
@@ -268,14 +305,17 @@ test("a narrow-screen palette pick keeps the view and dock, and the side region
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const terminal = page.locator("#terminal-panel")
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const views = page.getByRole("dialog", { name: "More options", exact: true })
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
await expect(terminal.locator("textarea")).toBeEditable()
await page.keyboard.press("ControlOrMeta+p")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox").fill("README")
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Terminal")
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("More...")
await expect(terminal).toHaveAttribute("data-opened", "true")
await page.setViewportSize({ width: 1280, height: 900 })
@@ -859,6 +859,45 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
const bubble = pending.locator('[data-slot="user-message-text"]')
const colors =
delivery === "steer"
? {
delivered: await userRow(page, userID)
.locator('[data-slot="user-message-text"]')
.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
text: getComputedStyle(element).color,
})),
pending: await bubble.evaluate((element) => {
const probe = document.createElement("span")
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
probe.style.color = "var(--v2-text-text-base)"
element.appendChild(probe)
const colors = {
background: getComputedStyle(probe).backgroundColor,
text: getComputedStyle(probe).color,
}
probe.remove()
return colors
}),
}
: undefined
if (colors) {
await expect(bubble).toHaveCSS("background-color", colors.pending.background)
await expect(bubble).toHaveCSS("color", colors.pending.text)
await pending.hover()
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(
/^Pending\s*·\s*Build\s*·\s*Queue Model$/,
)
await bubble.evaluate((element) => (element.dataset.deliveryMarker = "pending"))
}
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", {
sessionID,
@@ -921,6 +960,18 @@ for (const delivery of ["steer", "queue"] as const) {
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveCount(0)
await expect(pending).toHaveCount(1)
if (colors) {
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Build\s*·\s*Queue Model$/)
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)
await expect(bubble).toHaveCSS("color", colors.delivered.text)
await expect(bubble).toHaveCSS("transition-property", "background-color, color")
await transcript.screenshot({ path: testInfo.outputPath("delivered-steer.png") })
await page.emulateMedia({ reducedMotion: "reduce" })
await expect(bubble).toHaveCSS("transition-duration", "0s")
}
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
"data-message-id",
+83 -3
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/Projects/settings-demo"
@@ -463,7 +464,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
worktrees: () => inventory,
onWorktreeRemove: (body) => {
inventory.splice(
inventory.findIndex((item) => item.directory === (body as { directory: string }).directory),
inventory.findIndex((item) => item.directory === body.directory),
1,
)
},
@@ -499,6 +500,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
// Home never listed this session, so only the new worktree's directory read can show it.
view.sessions.push(session({ id: "ses_discovered", directory: discovered, title: "Discovered session", projectID }))
inventory.push({ directory: discovered, strategy: "git" })
// SAFETY: a worktree.updated event carries only the project ID, which is all the app reads from it.
await view.push([
{ id: "evt_settings_worktree_updated", created: Date.now(), type: "worktree.updated", data: { projectID } },
] as OpenCodeEvent[])
@@ -582,7 +584,9 @@ test.describe("worktrees prefetch", () => {
test("server Worktrees hover only prefetches metadata", async ({ page }) => {
const { settings } = await open(page)
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }
const calls = { projects: 0, worktrees: new Array<string>(), refreshes: new Array<string>() }
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/worktree/refresh")
calls.refreshes.push(request.postDataJSON().projectID)
@@ -754,7 +758,9 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
await expect(editor.getByLabel("Pairing link", { exact: true })).toBeFocused()
await expect(editor.getByPlaceholder("password")).toHaveCount(0)
await editor.getByRole("button", { name: "Use password", exact: true }).click()
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
await name.click()
@@ -766,6 +772,80 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await expect(settings).toBeVisible()
})
test("the add server dialog pairs from a one-time link and explains a spent one", async ({ page }) => {
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
const { settings } = await open(page)
const link = `${paired}/auth/connect/one-time-code`
const addServer = async () => {
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(link)
await editor.getByRole("button", { name: "Add server", exact: true }).click()
return editor
}
const added = await addServer()
await expect(added).toBeHidden()
await expect(settings.getByText(paired, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to settings" }).click()
const spent = await addServer()
await expect(spent.getByRole("alert")).toHaveText(
"This pairing link expired or was already used. Run opencode pair to get a new one.",
)
})
test("a pairing code with several addresses keeps the first one that works with its token", async ({ page }) => {
// Both addresses reach one server. The second spends the code first, so the first answers 401, and the token works on
// it as on every address of that server.
const first = "http://127.0.0.1:4098"
const second = "http://127.0.0.1:4099"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
for (const [server, pairing] of [
[first, { code: "spent-by-the-other-address", token: "session-token" }],
[second, { code: "one-time-code", token: "session-token" }],
] as const) {
await mockOpenCodeServer(page, {
server,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing,
})
}
const { settings } = await open(page)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor
.getByLabel("Pairing link", { exact: true })
.fill(JSON.stringify({ code: "one-time-code", urls: [first, second] }))
await editor.getByRole("button", { name: "Add server", exact: true }).click()
await expect(editor).toBeHidden()
await expect(settings.getByText(first, { exact: true })).toBeVisible()
await expect(settings.getByText(second, { exact: true })).toHaveCount(0)
})
test("the tab layout preference switches to vertical tabs and survives reload", async ({ page }) => {
const { settings } = await open(page)
const layout = settings.locator('[data-action="settings-tab-layout"]')
@@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref, draftHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const direction of ["ltr", "rtl"] as const) {
test(`bottom mobile navigation adds no extra top panel gutter in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, "/", direction)
const panel = page.locator('[data-slot="home-panel"]')
await expect(panel).toHaveCSS("--shell-top-inset", "0px")
await expect
.poll(async () => {
const titlebar = await page.locator('[data-slot="titlebar-v2"]').boundingBox()
const main = await page.getByRole("main").boundingBox()
return titlebar && main && titlebar.y >= main.y + main.height
})
.toBe(true)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(0)
})
for (const screen of [
{ name: "home", href: () => "/", panel: (page: Page) => page.locator('[data-slot="home-panel"]') },
{
name: "new-session",
href: () => draftHref("shell-spacing-draft"),
panel: (page: Page) => page.locator('[data-component="new-session"]'),
},
{
name: "session",
href: () => sessionHref(fixture.targetID),
panel: (page: Page) => page.locator('[data-slot="session-chat-panel"]'),
},
{ name: "settings", href: () => "/settings", panel: (page: Page) => page.getByTestId("settings-screen") },
]) {
test(`${screen.name} keeps an 8px top gutter and only inline border clearance on mobile in ${direction}`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
if (screen.name === "new-session") await installStressSessionTabs(page, { draftID: "shell-spacing-draft" })
await openWithDirection(page, screen.href(), direction)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const panel = screen.panel(page)
await expect(panel).toBeVisible()
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(8)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 1, width: 2 })
// The existing desktop gutter must return at the responsive breakpoint.
await page.setViewportSize({ width: 768, height: 900 })
await expect(panel).toHaveCSS("--shell-inline-inset", "8px")
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
if (screen.name === "session") return
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 8, width: 16 })
})
}
}
@@ -106,6 +106,37 @@ for (const mount of ["cached", "explicit"] as const) {
})
}
test("reconnects the same terminal after a transient connection failure", async ({ page }) => {
const { pty } = await openSession(page, workspace)
let tickets = 0
await page.route(/\/api\/pty\/[^/]+\/connect-token/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
tickets += 1
if (tickets !== 2) return route.fallback()
return route.fulfill({
status: 503,
headers: { "access-control-allow-origin": "*" },
body: "Service Unavailable",
})
})
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => pty.sockets.length).toBe(1)
const id = pty.sockets[0]!.id
await terminal.evaluate((element) => element.setAttribute("data-connection-probe", "original"))
await pty.sockets[0]!.close(1011, "Temporary disconnection")
await expect.poll(() => tickets).toBe(3)
await expect.poll(() => pty.sockets.length).toBe(2)
expect(pty.sockets[1]!.id).toBe(id)
expect(pty.rejected).toEqual([])
await expect(terminal).toHaveAttribute("data-connection-probe", "original")
expect(pty.created.map((item) => item.id)).toEqual([id])
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const { editor } = await openSession(page, workspace)
const terminal = page.locator('[data-component="terminal"]')
@@ -1012,6 +1012,99 @@ test.describe("background shortcut", () => {
await expect(backgroundCard).toContainText("Background task (background)")
})
for (const fixture of [
{ name: "from far below", before: 40, searches: 40, after: 80 },
// The collapsed timeline fits the viewport, so it only becomes scrollable once the group opens.
{ name: "that opens the last row", before: 0, searches: 40, after: 0 },
// Reaching the shell lands on the end of the timeline, which would otherwise follow new content again.
{ name: "at the end of the timeline", before: 40, searches: 0, after: 0 },
]) {
test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionStatus: { [sessionID]: { type: "busy" } },
messages: [
userMessage(),
assistantMessage(
[
...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []),
...searches(0, 42 - fixture.searches),
toolPart(
"prt_far_shell",
"shell",
"completed",
{ command: "sleep 120" },
{ output: "working", metadata: { shellID: "shell_far", status: "running" } },
),
// The open group outgrows the viewport, so aligning the group instead of the shell misses it.
...searches(42 - fixture.searches, fixture.searches),
...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []),
],
{ completed: false },
),
],
})
await timeline.transport.send({
id: "evt_far_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_far",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/far.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const group = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]')
const aligned = () =>
expect
.poll(async () => {
const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()])
return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined
})
.toBe(0)
await expect(
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
).toBeInViewport()
await expect(shellTrigger).toHaveCount(0)
await page.getByRole("button", { name: "1 running", exact: true }).click()
await page
.getByRole("menu", { name: "1 running", exact: true })
.getByRole("menuitem", { name: /sleep 120/ })
.click()
await expect(group).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toBeInViewport()
// A shell at the very end cannot rise under the header until more content arrives below it.
if (fixture.searches) await aligned()
// Nothing covers the row, so it takes the next click.
await shellTrigger.click({ trial: true })
// The agent keeps writing below the shell; the shell stays where it was revealed.
await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30))))
await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached()
await aligned()
})
}
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
const childID = "ses_only_running_child"
@@ -1735,6 +1828,16 @@ function pauseExitAnimations(locator: Locator) {
})
}
function notes(label: string, count: number) {
return Array.from({ length: count }, (_, index) => `${label} note ${index + 1}.`).join("\n\n")
}
function searches(from: number, count: number) {
return Array.from({ length: count }, (_, index) =>
toolPart(`prt_search_${from + index}`, "grep", "completed", { pattern: `needle_${from + index}` }),
)
}
function runningSubagent(): SessionMessageAssistant {
return {
...completed,
+26 -18
View File
@@ -1,13 +1,14 @@
import { expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import type { AddressInfo } from "node:net"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { serviceWorker } from "../../vite.pwa"
import { onDemandAsset, serviceWorker } from "../../vite.pwa"
type Site = {
url: string
@@ -127,14 +128,14 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
const file = builds[state.version][path]
const types: Record<string, string> = {
".js": "text/javascript",
".html": "text/html",
".json": "application/json",
".wasm": "application/wasm",
}
const types = new Map([
[".js", "text/javascript"],
[".html", "text/html"],
[".json", "application/json"],
[".wasm", "application/wasm"],
])
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
response.setHeader("content-type", types.get(extname(path)) ?? "application/octet-stream")
if (file) return void response.end(file)
@@ -145,9 +146,8 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
try {
await use({
@@ -284,9 +284,11 @@ fixture(
await expect
.poll(() =>
replacement.evaluate(() => {
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
// This runs in the service worker, whose global scope holds its registration.
const registration =
"registration" in self && self.registration instanceof ServiceWorkerRegistration ? self.registration : undefined
return { waiting: !!registration.waiting, active: registration.active?.state }
return { waiting: !!registration?.waiting, active: registration?.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
@@ -378,15 +380,22 @@ fixture("does not substitute cached HTML for API or missing asset navigations",
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file", async ({ page, context }) => {
test("the production build precaches every deployable file except on-demand Office assets", async ({
page,
context,
}) => {
const directory = new URL("../../dist/", import.meta.url)
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
const deployable = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
const files = deployable.filter((path) => !onDemandAsset(path))
expect(files.length).toBeGreaterThan(1)
// The Office engines ship but load on demand, so a build without them would make this exclusion vacuous.
expect(deployable.some((path) => path.endsWith(".wasm") && onDemandAsset(path))).toBe(true)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
@@ -411,9 +420,8 @@ test("the production build precaches every deployable file", async ({ page, cont
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const url = `http://127.0.0.1:${address.port}`
try {
@@ -56,7 +56,6 @@ export function mount(input: { server: string; route: string; direction: Directi
return (
<AppInterface
servers={[server]}
defaultServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)}
router={(props) => <MemoryRouter {...props} history={history} />}
>
+12 -4
View File
@@ -63,6 +63,12 @@ export class MockShellNotFound extends Schema.TaggedError<MockShellNotFound>()("
message: Schema.String,
}) {}
// The server's error for an unknown PTY, or one owned by another workspace.
export class MockPtyNotFound extends Schema.TaggedError<MockPtyNotFound>()("PtyNotFoundError", {
ptyID: Schema.String,
message: Schema.String,
}) {}
// The server's error for a request without its password.
export class MockUnauthorized extends Schema.TaggedError<MockUnauthorized>()("UnauthorizedError", {
message: Schema.String,
@@ -75,6 +81,8 @@ export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("Mock
const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))
const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))]
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
@@ -242,7 +250,7 @@ const Group = HttpApiGroup.make("mock")
HttpApiEndpoint.get("ptyGet", "/api/pty/:ptyID", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
@@ -250,21 +258,21 @@ const Group = HttpApiGroup.make("mock")
params: PtyParams,
payload: Pty.UpdateInput,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.delete("ptyRemove", "/api/pty/:ptyID", {
params: PtyParams,
success: NoContent,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.post("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
+38 -2
View File
@@ -11,6 +11,7 @@ import {
MockBadRequest,
MockInternal,
MockNotFound,
MockPtyNotFound,
MockShellNotFound,
MockUnauthorized,
MockUnsupported,
@@ -135,6 +136,8 @@ export interface MockServerConfig {
strictDirectory?: boolean
// Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test.
password?: Resolvable<string>
// Serves GET /auth/connect/:code: `code` redeems once for `token` (send it as the password); others answer 401.
pairing?: { code: string; token: string }
}
export type MockPtyInfo = {
@@ -147,7 +150,14 @@ export type MockPtyInfo = {
pid: number
}
export type MockPtySocket = { id: string; url: URL; input: string[]; closed: boolean; send(data: string): void }
export type MockPtySocket = {
id: string
url: URL
input: string[]
closed: boolean
send(data: string): void
close(code: number, reason: string): Promise<void>
}
export type MockPty = {
list: MockPtyInfo[]
@@ -401,6 +411,29 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
})
if (config.pairing) {
const pairing = { ...config.pairing, redeemed: false }
await page.route(`${server}/auth/connect/*`, (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: corsHeaders })
const code = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1) ?? "")
if (code !== pairing.code || pairing.redeemed) {
return route.fulfill({
status: 401,
headers: corsHeaders,
json: Schema.encodeSync(MockUnauthorized)(
new MockUnauthorized({ message: "Pairing link expired or already used" }),
),
})
}
pairing.redeemed = true
return route.fulfill({ headers: corsHeaders, json: { token: pairing.token } })
})
}
if (config.pty) {
const host = new URL(server).host
await page.routeWebSocket(
@@ -422,6 +455,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
input: [],
closed: false,
send: (data) => ws.send(data),
close: (code, reason) => ws.close({ code, reason }),
}
ws.onMessage((message) => socket.input.push(message.toString()))
@@ -672,7 +706,9 @@ function mockHandlers(
const directory = requestDirectory(config, request)
const found = state.pty.find(id, directory)
return found ? Effect.succeed(found) : Effect.fail(new MockNotFound({ message: "PTY not found" }))
return found
? Effect.succeed(found)
: Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY not found: ${id}` }))
}),
),
)
@@ -241,11 +241,7 @@ export function mount(input: {
return (
<Show when={extensions.ready()}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
<AppInterface servers={servers()} router={(props) => <MemoryRouter {...props} history={history} />} />
</Show>
)
}
@@ -4,7 +4,6 @@ import { render } from "solid-js/web"
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
// The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output.
export function mount(input: { server: string }) {
@@ -59,7 +58,6 @@ export function mount(input: { server: string }) {
<output aria-label="Desktop menu actions">{store.actions.join(",")}</output>
<AppInterface
servers={[{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } }]}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.24",
"description": "",
"type": "module",
"exports": {
+1 -6
View File
@@ -86,7 +86,6 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
@@ -108,11 +107,7 @@ export function AppInterface(props: {
)
return (
<ServersProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<ServersProvider canonicalLocalServer={props.canonicalLocalServer} servers={props.servers}>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
+26 -6
View File
@@ -56,19 +56,39 @@ export type ComposerSession = {
clear: (messageID: string) => void
}
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
switchAgent: (input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0]) => Promise<unknown>
switchModel: (input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0]) => Promise<unknown>
command: (
input: Parameters<ServerSDK["api"]["session"]["command"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["command"]>
shell: (
input: Parameters<ServerSDK["api"]["session"]["shell"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["shell"]>
switchAgent: (
input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["switchAgent"]>
switchModel: (
input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["switchModel"]>
revert: {
commit: (
input: Parameters<ServerSDK["api"]["session"]["revert"]["commit"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["revert"]["commit"]>
}
}
data: {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => ReturnType<Data["session"]["prompt"]>
setStatus: Data["session"]["setStatus"]
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
current: Accessor<
| {
agent?: string
model?: { id: string; providerID: string; variant?: string }
revert?: { messageID: string }
}
| undefined
>
admitted: (messageID: string) => boolean
}
@@ -2,6 +2,7 @@ import type { Accessor } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { authTokenFromCredentials } from "@/runtime/server/api"
import { uuid } from "@/runtime/persistence/uuid"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
@@ -29,7 +30,7 @@ export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
// normalizes the separators and returns the resolved path.
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
url.searchParams.set("location[directory]", location().directory)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${uuid()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
@@ -50,6 +51,7 @@ function write(url: URL, file: File, password: string | undefined, report: (load
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
xhr.addEventListener("load", () => {
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
// SAFETY: a 200 from `fs.write` is its declared success body, `Location.response(FileSystem.Write)`.
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
+2 -2
View File
@@ -1,5 +1,6 @@
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { DialogSelectModel } from "@/providers/models/select-dialog"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
@@ -33,7 +34,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
void dialog.show(() => <DialogConnectProvider directory={workspace().directory} />)
}
const chooseModel = async () => {
const chooseModel = () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
const selection = window.getSelection()
@@ -54,7 +55,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
})
}
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
})
+39 -110
View File
@@ -1,123 +1,52 @@
import type { SessionMessageUser } from "@opencode/client/promise"
import { Option, Schema } from "effect"
import type { FileSelection } from "@/workspaces/files/model"
import { Persistence } from "@/runtime/persistence/schema"
import { uuid } from "@/runtime/persistence/uuid"
import { FileSelection } from "@/workspaces/files/types"
import { durableNote, LegacyBrowserNote, NoteComment, type ContextItem } from "./schema"
export type PromptFileComment = {
type?: "file"
path: string
selection?: FileSelection
comment: string
preview?: string
origin?: "review" | "file"
}
export const PromptFileComment = Persistence.struct({
type: Schema.optional(Schema.Literal("file")),
path: Schema.String,
selection: Persistence.optional(FileSelection),
comment: Schema.String,
preview: Persistence.optional(Schema.String),
origin: Persistence.optional(Schema.Literals(["review", "file"])),
})
export type PromptFileComment = typeof PromptFileComment.Type
export type PromptComment = PromptFileComment | NoteComment
const decodeNoteComment = Schema.decodeUnknownOption(NoteComment)
const decodeLegacyBrowserNote = Schema.decodeUnknownOption(LegacyBrowserNote)
/** An attachment the model receives as a path on the server rather than inline bytes. */
export type PromptAttachmentReference = {
name: string
mime: string
path: string
}
export const PromptAttachmentReference = Persistence.struct({
name: Schema.String,
mime: Schema.String,
path: Schema.String,
})
function selection(selection: unknown) {
if (!selection || typeof selection !== "object") return undefined
const startLine = Number((selection as FileSelection).startLine)
const startChar = Number((selection as FileSelection).startChar)
const endLine = Number((selection as FileSelection).endLine)
const endChar = Number((selection as FileSelection).endChar)
export type PromptAttachmentReference = typeof PromptAttachmentReference.Type
if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
// Presentation metadata needs a comments list, which prompts from other clients lack; a malformed comment or
// attachment is skipped rather than discarding the rest.
const decodePromptPresentation = Schema.decodeUnknownOption(
Schema.Struct({
displayText: Schema.String,
comments: Schema.Array(Schema.Unknown),
attachments: Persistence.array(PromptAttachmentReference),
}),
)
return {
startLine,
startChar,
endLine,
endChar,
} satisfies FileSelection
}
const decodePromptComment = Schema.decodeUnknownOption(Schema.Union([NoteComment, LegacyBrowserNote, PromptFileComment]))
export function createCommentMetadata(input: PromptFileComment) {
return {
opencodeComment: {
path: input.path,
selection: input.selection,
comment: input.comment,
preview: input.preview,
origin: input.origin,
},
}
}
export function readCommentMetadata(value: unknown) {
if (!value || typeof value !== "object") return
const meta = (value as { opencodeComment?: unknown }).opencodeComment
if (!meta || typeof meta !== "object") return
const path = (meta as { path?: unknown }).path
const comment = (meta as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return
const preview = (meta as { preview?: unknown }).preview
const origin = (meta as { origin?: unknown }).origin
return {
path,
selection: selection((meta as { selection?: unknown }).selection),
comment,
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
} satisfies PromptComment
}
export function readPromptPresentation(value: unknown) {
if (!value || typeof value !== "object") return
const displayText = (value as { displayText?: unknown }).displayText
const comments = (value as { comments?: unknown }).comments
if (typeof displayText !== "string" || !Array.isArray(comments)) return
const attachments = (value as { attachments?: unknown }).attachments
return {
displayText,
attachments: (Array.isArray(attachments) ? attachments : []).flatMap((item): PromptAttachmentReference[] => {
if (!item || typeof item !== "object") return []
const name = (item as { name?: unknown }).name
const mime = (item as { mime?: unknown }).mime
const path = (item as { path?: unknown }).path
if (typeof name !== "string" || typeof mime !== "string" || typeof path !== "string") return []
return [{ name, mime, path }]
}),
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
if ((item as { type?: unknown }).type === "note") return Option.toArray(decodeNoteComment(item))
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeLegacyBrowserNote(item))
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
const preview = (item as { preview?: unknown }).preview
const origin = (item as { origin?: unknown }).origin
return [
{
path,
comment,
selection: selection((item as { selection?: unknown }).selection),
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
},
]
}),
}
export function readPromptPresentation(metadata: SessionMessageUser["metadata"]) {
return Option.getOrUndefined(
Option.map(decodePromptPresentation(metadata), (presentation) => ({
displayText: presentation.displayText,
attachments: presentation.attachments,
comments: presentation.comments.flatMap((item): PromptComment[] => Option.toArray(decodePromptComment(item))),
})),
)
}
export function formatAttachmentReference(input: PromptAttachmentReference) {
@@ -132,7 +61,7 @@ export function formatNoteComment(input: NoteComment) {
/** Restores a sent comment to the composer, for example after a revert or fork. */
export function commentContextItem(comment: PromptComment): ContextItem {
// The message may predate this app process, so a note's live references can no longer be trusted.
if (comment.type === "note") return { ...durableNote(comment), commentID: crypto.randomUUID() }
if (comment.type === "note") return { ...durableNote(comment), commentID: uuid() }
return {
type: "file",
+6 -2
View File
@@ -1,4 +1,5 @@
import { Show, createMemo } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
@@ -66,6 +67,7 @@ function ComposerModelControl(props: {
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const mobile = createMediaQuery("(max-width: 767px)")
const content = () => (
<>
@@ -87,6 +89,7 @@ function ComposerModelControl(props: {
return (
<Show when={!props.loading}>
<Tooltip
inactive={mobile()}
placement="top"
gutter={4}
value={
@@ -97,7 +100,7 @@ function ComposerModelControl(props: {
}
>
<Show
when={props.paid}
when={props.paid || mobile()}
fallback={
<Button
data-action="composer-model"
@@ -115,6 +118,7 @@ function ComposerModelControl(props: {
>
<ModelSelectorPopover
model={props.model}
unpaid={!props.paid}
trigger={(triggerProps) => (
<Button
{...triggerProps}
@@ -124,7 +128,7 @@ function ComposerModelControl(props: {
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="composer-model"
data-control-type="popover"
data-control-type={mobile() ? "drawer" : "popover"}
>
{content()}
</Button>
+5 -3
View File
@@ -25,9 +25,11 @@
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
@media (hover: hover) {
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
+112 -25
View File
@@ -8,10 +8,12 @@ import {
onMount,
Show,
Suspense,
lazy,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -46,6 +48,12 @@ import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export type {
ComposerAttachment,
ComposerComment,
@@ -350,6 +358,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
mobileDrawer
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
@@ -804,6 +813,7 @@ export function ComposerEditorAddMenu(props: {
}
function ComposerEditorConfiguredSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
control: ComposerSelectControl
@@ -815,6 +825,7 @@ function ComposerEditorConfiguredSelect(props: {
return (
<ComposerEditorSelect
mobileDrawer={props.mobileDrawer}
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
@@ -831,6 +842,7 @@ function ComposerEditorConfiguredSelect(props: {
}
export function ComposerEditorSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
options: ComposerOption[]
@@ -840,8 +852,36 @@ export function ComposerEditorSelect(props: {
onOpenChange?: (open: boolean) => void
onSelect: (id: string) => void
}) {
const mobile = createMediaQuery("(max-width: 767px)")
const [store, setStore] = createStore({ open: false, loaded: false })
let trigger: HTMLButtonElement | undefined
const setOpen = (open: boolean) => {
setStore("open", open)
if (open) setStore("loaded", true)
props.onOpenChange?.(open)
}
createEffect(() => {
if (!mobile() && store.open) setOpen(false)
})
const content = () => (
<>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Tooltip
inactive={props.mobileDrawer && mobile()}
placement="top"
value={
<>
@@ -850,36 +890,83 @@ export function ComposerEditorSelect(props: {
</>
}
>
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
<Show
when={props.mobileDrawer && mobile()}
fallback={
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
>
{content()}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
}
>
<Button
ref={(element: HTMLButtonElement) => {
trigger = element
}}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setOpen(true)}
>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
{content()}
</Button>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={props.title}
open={store.open}
onOpenChange={setOpen}
returnFocus={() => trigger}
>
<div class="flex flex-col overflow-hidden rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-02 divide-y divide-v2-border-border-base">
<For each={props.options}>
{(option) => (
<Button
variant="ghost"
class="w-full !h-10 !justify-start !rounded-none !px-3 !font-[440] capitalize focus-visible:!outline-offset-[-2px]"
aria-pressed={props.current === option.id}
data-state={props.current === option.id ? "pressed" : undefined}
onClick={() => {
props.onSelect(option.id)
setOpen(false)
}}
>
{option.label}
<Show when={props.current === option.id}>
<Icon name="check" class="ms-auto" />
</Show>
</Button>
)}
</For>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</Show>
</Tooltip>
)
}
@@ -204,7 +204,7 @@ export function createComposerEditor(input: {
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (!action) result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(draft.state.prompt.filter(isAttachment), 0)
+20
View File
@@ -130,6 +130,11 @@ function session(input: {
input.calls.push("shell")
}),
command: input.command ?? (async () => undefined),
revert: {
commit: async () => {
input.calls.push("revert-commit")
},
},
},
data: {
location: { command: { list: () => [] } },
@@ -138,6 +143,16 @@ function session(input: {
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt?.(value)
// The admitted inbox item, as the server returns it.
return {
id: value.id ?? "msg_admitted",
sessionID: value.sessionID,
time: { created: 0 },
type: "user" as const,
payload: { text: value.text },
delivery: value.delivery ?? "steer",
}
},
},
},
@@ -183,6 +198,11 @@ describe("Composer submission", () => {
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
calls: ["switch-model"],
},
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
{
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
calls: ["switch-agent", "revert-commit", "switch-model"],
},
])("applies the selection before sending one captured value: $calls", async (row) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
+12 -2
View File
@@ -332,6 +332,8 @@ function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<type
submission.target().mode.set("normal")
input.setMode("normal")
input.closePopover()
if (window.matchMedia("(max-width: 767px)").matches) input.editor()?.blur()
}
function restoreSubmission(
@@ -455,6 +457,7 @@ async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
beforeModel?: () => Promise<void>,
) {
const cancel = track?.(session.id, selection)
@@ -465,6 +468,8 @@ async function applySelection(
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
await beforeModel?.()
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
@@ -489,10 +494,15 @@ async function sendPrompt(
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
// Like the TUI, a staged revert settles after the agent switch and before the model switch and admission. The
// server would otherwise commit it on admission and delete every row from its boundary on, the model switch too.
const settle = async () => {
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
}
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
else await settle()
const admission = {
id: value.id,
sessionID: session.id,
+1 -2
View File
@@ -16,7 +16,7 @@ import pkg from "../package.json"
import { ServerConnection } from "@/runtime/server/registry"
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
if (typeof navigator === "undefined") return "en" as const
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
@@ -101,7 +101,6 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
>
+23 -14
View File
@@ -31,7 +31,6 @@ export function createHomeProjectsController(home: HomeController) {
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = ServerConnection.authenticate
const revealProject = useRevealProject()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
@@ -41,6 +40,23 @@ export function createHomeProjectsController(home: HomeController) {
{ initialValue: _state },
)
function edit(conn: ServerConnection.Http, onSave?: (saved: ServerConnection.Http) => void) {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} onSave={(saved) => onSave?.(saved)} />)
})
}
// An expired pairing session or a changed password signs the app out of an HTTP server; signing in again edits it,
// and the action that asked for sign-in continues once the dialog saves, as it does for extension servers. It continues
// with the saved connection: the one it started with still carries the rejected credentials.
function authenticate(conn: ServerConnection.Any, onConnected?: (conn: ServerConnection.Any) => void) {
if (conn.type !== "http" || !home.server.health(conn)?.unauthorized)
return ServerConnection.authenticate(conn, () => onConnected?.(conn))
edit(conn, onConnected)
return true
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
@@ -70,22 +86,14 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
edit: (conn: ServerConnection.Http) => edit(conn),
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => home.selection.focusServer(conn))) return
if (authenticate(conn, (next) => home.selection.focusServer(next))) return
home.selection.focusServer(conn)
},
},
@@ -94,12 +102,12 @@ export function createHomeProjectsController(home: HomeController) {
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
if (authenticate(conn, (next) => home.project.select(next, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
if (authenticate(conn, (next) => home.project.openProjectNewSession(next, directory))) return
home.project.openProjectNewSession(conn, directory)
},
canImportSession: !!platform.openAttachmentPickerDialog,
@@ -119,6 +127,7 @@ export function createHomeProjectsController(home: HomeController) {
const api = home.server.context(conn).sdk.api.session
// SAFETY: the payload is SessionTransfer.Data's own encoding, which the import endpoint decodes and validates.
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
@@ -152,7 +161,7 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => choose(conn))) return
if (authenticate(conn, (next) => choose(next))) return
if (home.server.health(conn)?.healthy === false) return
choose(conn)
@@ -19,8 +19,6 @@ export function HomeProjects(props: {
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault()}
defaultServerKey={props.projects.server.defaultKey()}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
@@ -29,7 +27,6 @@ export function HomeProjects(props: {
onAuthenticateServer={props.projects.server.authenticate}
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
canHideServer={props.projects.server.canHide}
+11 -14
View File
@@ -33,6 +33,11 @@ const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whi
const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
// Extension servers ask for sign-in themselves; an HTTP server that rejects its saved credentials needs a new
// pairing link or password.
const signInRequired = (server: ServerConnection.Any, health: ServerHealth | undefined) =>
(server.type === "extension" && server.authenticationRequired) || (server.type === "http" && !!health?.unauthorized)
const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
`project:${ServerConnection.key(server)}:${directory}`
@@ -47,8 +52,6 @@ export type HomeProjectsViewProps = {
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
canDefaultServer: boolean
defaultServerKey: ServerConnection.Key | null | undefined
canRevealProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
@@ -57,7 +60,6 @@ export type HomeProjectsViewProps = {
onAuthenticateServer?: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
canHideServer: (server: ServerConnection.Any) => boolean
@@ -158,7 +160,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const [contextMenu, setContextMenu] = createStore<{ open: string | undefined }>({ open: undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -216,7 +218,9 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
when={
props.servers.length > 1 ||
props.servers.some(
(server) => server.type === "extension" && (server.authenticationRequired || server.connecting),
(server) =>
signInRequired(server, props.serverHealth(server)) ||
(server.type === "extension" && server.connecting),
)
}
fallback={
@@ -253,7 +257,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
const authentication = () => item.type === "extension" && item.authenticationRequired
const authentication = () => signInRequired(item, props.serverHealth(item))
const connecting = () => item.type === "extension" && item.connecting
return (
@@ -340,12 +344,9 @@ function HomeServerRow(props: {
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
canHideServer: HomeProjectsViewProps["canHideServer"]
@@ -358,7 +359,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const authentication = () => props.server.type === "extension" && props.server.authenticationRequired
const authentication = () => signInRequired(props.server, props.health)
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
@@ -463,13 +464,9 @@ function HomeServerRow(props: {
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
canHide={props.canHideServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
onHide={() => props.onHideServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
+2 -1
View File
@@ -20,8 +20,9 @@ export function Home() {
return (
<div
data-slot="home-panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
mx-[var(--shell-inline-inset,8px)] mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
@@ -8,6 +8,7 @@ import {
createCommandPaletteCommandEntry,
createServerSessionEntries,
type CommandPaletteEntry,
type PaletteHighlight,
} from "@/shell/commands/palette"
import { CommandPaletteView, matchesCommandPaletteEntry } from "@/shell/commands/dialog"
@@ -20,7 +21,7 @@ export function HomeCommandPalette(props: {
const global = useGlobal()
const language = useLanguage()
const server = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const state: PaletteHighlight = { cleanup: undefined, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
@@ -36,6 +37,7 @@ export function HomeCommandPalette(props: {
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
recentCategory: () => language.t("palette.group.recentSessions"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
@@ -1,3 +1,4 @@
import { Predicate } from "effect"
import { base64Encode } from "@opencode/util/encode"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Session } from "@opencode/schema/session"
@@ -47,7 +48,10 @@ export function createNewSessionComposerAdapter(props: {
async start(selection, submission, message) {
const draftID = props.draftID
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const projectDirectory =
data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
@@ -163,6 +167,7 @@ export function createNewSessionComposerAdapter(props: {
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
revert: { commit: (input) => afterCreation(() => serverSDK.api.session.revert.commit(input)) },
},
data: {
location: data.location,
@@ -239,16 +244,16 @@ async function resolveSessionDirectory(input: {
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
return error.message
}
function errorMessage(language: ReturnType<typeof useLanguage>, cause: unknown) {
if (Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)) return cause.message
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (
Predicate.hasProperty(cause, "data") &&
Predicate.hasProperty(cause.data, "message") &&
Predicate.isString(cause.data.message) &&
cause.data.message
)
return cause.data.message
return language.t("common.requestFailed")
}
@@ -341,7 +341,7 @@ export function PromptProjectSelector(props: {
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
@@ -402,16 +402,16 @@ export function PromptProjectSelector(props: {
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<div class="my-0.5 max-h-[224px] overflow-y-auto">
<Show
when={props.controller.servers().length > 1}
fallback={
@@ -451,8 +451,8 @@ export function PromptProjectSelector(props: {
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col">
<div class="-mx-0.5 h-px bg-v2-border-border-muted" />
<div class="flex flex-col pt-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
+1 -1
View File
@@ -103,7 +103,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@ export function NewSessionView(props: {
<PromptProjectAddButton controller={props.project} />
</Show>
<Show when={props.project.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
<PromptProjectSelector controller={props.project} placement="bottom" />
<Show
when={props.workspace.bar.visible()}
@@ -327,7 +327,7 @@ export function PromptWorkspaceSelector(props: {
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
@@ -355,7 +355,7 @@ export function PromptWorkspaceSelector(props: {
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
@@ -363,7 +363,7 @@ export function PromptWorkspaceSelector(props: {
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
@@ -7,14 +7,23 @@ import type {
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { formatServerError } from "@/runtime/server/errors"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" | "external" }>
type Authorization = IntegrationOauthConnectOutput["data"]
type Polling = {
generation: number
timer?: ReturnType<typeof setTimeout>
disposed: boolean
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt?: Authorization
}
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
@@ -35,7 +44,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (actual === undefined) return false
const equal = Array.isArray(actual)
? typeof condition.value === "string" && actual.includes(condition.value)
? actual.some((item) => item === condition.value)
: actual === condition.value
return condition.op === "eq" ? equal : !equal
@@ -43,7 +52,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (!active) return answer
return { ...answer, [field.key]: field.default }
return Object.assign(answer, { [field.key]: field.default })
}, {})
}
@@ -74,9 +83,8 @@ export function createProviderConnectionController(options: {
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
const [integration, setIntegration] = createStore({
const [integration, setIntegration] = createStore<{ loading: boolean; latest?: IntegrationInfo }>({
loading: true,
latest: undefined as IntegrationInfo | undefined,
})
createEffect(
@@ -100,7 +108,8 @@ export function createProviderConnectionController(options: {
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
(method): method is ProviderConnectMethod =>
method.type === "key" || method.type === "oauth" || method.type === "external",
)
if (values?.length) return [...values]
@@ -108,14 +117,19 @@ export function createProviderConnectionController(options: {
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
const [store, setStore] = createStore<{
methodIndex?: number
authorization?: Authorization
formAnswer?: FormAnswer
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
// value would keep multi-method providers on the spinner instead of the method list.
state: undefined as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
state?: "pending" | "waiting" | "refreshing" | "ready" | "error" | "form"
error?: string
auto: boolean
connected: boolean
browserFailed: boolean
statusFailed: boolean
}>({
auto: false,
// The credential is stored; a retry only needs to reload the catalogs.
connected: false,
@@ -124,12 +138,9 @@ export function createProviderConnectionController(options: {
statusFailed: false,
})
const polling = {
const polling: Polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
@@ -206,8 +217,6 @@ export function createProviderConnectionController(options: {
)
}
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
@@ -276,7 +285,9 @@ export function createProviderConnectionController(options: {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.statusFailed") : errorMessage(result.error),
error: isConsole()
? language.t("provider.connect.console.statusFailed")
: formatServerError(result.error, language.t),
})
return
@@ -358,8 +369,6 @@ export function createProviderConnectionController(options: {
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: language.t("provider.connect.error.unsupportedFields") })
@@ -368,11 +377,35 @@ export function createProviderConnectionController(options: {
dispatch({ type: "auth.pending" })
if (selected.type === "external") {
const saved = await serverSDK.api.integration.connect
.external({
integrationID: options.provider(),
methodID: selected.id,
answer: Object.keys(merged).length ? merged : undefined,
location: location(),
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!saved.ok) {
dispatch({ type: "auth.error", error: formatServerError(saved.error, language.t) })
return
}
await finish()
return
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(Object.keys(merged).length ? { answer: merged } : {}),
answer: Object.keys(merged).length ? merged : undefined,
location: location(),
})
.then((response) => {
@@ -404,7 +437,9 @@ export function createProviderConnectionController(options: {
if (!result.ok) {
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.startFailed") : errorMessage(result.error),
error: isConsole()
? language.t("provider.connect.console.startFailed")
: formatServerError(result.error, language.t),
})
return
@@ -448,7 +483,7 @@ export function createProviderConnectionController(options: {
integrationID: options.keyProvider?.() ?? options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
answer: store.formAnswer,
})
await finish()
}
@@ -468,7 +503,8 @@ export function createProviderConnectionController(options: {
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) return errorMessage(result.error) || language.t("provider.connect.oauth.code.invalid")
if (!result.ok)
return formatServerError(result.error, language.t, language.t("provider.connect.oauth.code.invalid"))
await finish()
return undefined
@@ -496,11 +532,13 @@ export function createProviderConnectionController(options: {
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
// about to be picked automatically, the authorization request is in flight, or an external
// method, which has no view of its own, is refreshing the catalogs after saving.
busy: () =>
integration.loading ||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
store.state === "pending",
store.state === "pending" ||
(store.state === "refreshing" && currentMethod()?.type === "external"),
auth: {
state: () => store.state,
error: () => store.error,
@@ -66,8 +66,9 @@ export const OpenCodeZen = {
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "oauth", label: "Sign in with ChatGPT" },
{ type: "oauth", label: "Codex browser (legacy)" },
{ type: "oauth", label: "Codex device code (legacy)" },
{ type: "key", label: "API key" },
]),
}
+128 -28
View File
@@ -3,6 +3,7 @@ import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { Spinner } from "@opencode/ui/spinner"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
@@ -61,7 +62,7 @@ type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController() {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [store, setStore] = createStore<{ selected?: string }>({})
const reset = () => setStore("selected", undefined)
return {
@@ -83,9 +84,13 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const [state, setState] = createStore({
const [state, setState] = createStore<{
completed: boolean
modelProvider?: { id: string; name: string }
authorization: boolean
chatgptWelcome: boolean
}>({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
chatgptWelcome: false,
})
@@ -216,10 +221,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore({
const [store, setStore] = createStore<{
filter: string
active?: string
connecting?: string
}>({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
@@ -421,16 +428,24 @@ function ProviderConnection(props: {
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
const remote = isConsole && authServerName(sdk.server) !== undefined
const [state, setState] = createStore({
const [state, setState] = createStore<{
copied: boolean
copyFailed: boolean
firstConnection?: boolean
models: boolean
noModels: boolean
catalogPending: boolean
selectedModel: string
collapsed: Record<string, boolean>
}>({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
noModels: false,
// The workspace providers had not loaded when the wait ran out.
catalogPending: false,
selectedModel: "",
collapsed: {} as Record<string, boolean>,
collapsed: {},
})
const controller = createProviderConnectionController({
@@ -642,11 +657,22 @@ function ProviderConnection(props: {
function AuthFormView() {
const defaults = providerFormDefaults(controller.currentMethod()?.form)
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: Record<string, string>
index: number
customOn: boolean
customText: string
}>({
value: Object.fromEntries(
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
) as Record<string, string>,
(controller.currentMethod()?.form ?? []).flatMap((field) =>
field.type === "string" && field.key in defaults && field.default !== undefined
? [[field.key, field.default]]
: [],
),
),
index: 0,
customOn: false,
customText: "",
})
const fields = createMemo<StringForm[]>(() => {
@@ -677,14 +703,18 @@ function ProviderConnection(props: {
}
})
// Visible defaults prefill the field but enter the answer only once that field is answered,
// because the server rejects values for fields whose conditions are not met.
const answer = (field: StringForm) => formStore.value[field.key] ?? field.default
const valid = createMemo(() => {
const item = current()
if (!item || item.field.options) return false
if (!item || (item.field.options && !item.field.custom)) return false
if (!item.field.required) return true
return (formStore.value[item.field.key] ?? "").trim().length > 0
return (answer(item.field) ?? "").trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
@@ -694,7 +724,7 @@ function ProviderConnection(props: {
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) {
setFormStore("index", next)
setFormStore({ index: next, customOn: false, customText: "" })
return
}
@@ -706,9 +736,10 @@ function ProviderConnection(props: {
e.preventDefault()
const item = current()
if (!item || item.field.options) return
if (!item || !valid()) return
const value = answer(item.field)
if (!valid()) return
if (value !== undefined) setFormStore("value", item.field.key, value)
await next(item.index, formStore.value)
}
@@ -730,6 +761,15 @@ function ProviderConnection(props: {
return field
})
const custom = () => {
const field = select()
if (!field?.custom) return false
const value = answer(field)
return formStore.customOn || (value !== undefined && !field.options?.some((option) => option.value === value))
}
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
@@ -738,7 +778,7 @@ function ProviderConnection(props: {
type="text"
label={text()?.title ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
value={text() ? (answer(text()!) ?? "") : ""}
onChange={(value) => {
const field = text()
@@ -750,6 +790,62 @@ function ProviderConnection(props: {
{language.t("common.continue")}
</Button>
</Match>
<Match when={select()?.custom && select()}>
{(field) => (
<>
<div class="w-full flex flex-col gap-3">
<div class="text-14-regular text-text-base">{field().title}</div>
<RadioGroup
label={field().title}
hideLabel
description={field().description}
value={
custom()
? "custom"
: String(field().options?.findIndex((option) => option.value === answer(field())))
}
onChange={(value) => {
const option = field().options?.[Number(value)]
if (option) {
if (custom()) setFormStore("customText", answer(field()) ?? "")
setFormStore("customOn", false)
setFormStore("value", field().key, option.value)
return
}
setFormStore("customOn", true)
setFormStore("value", field().key, formStore.customText)
}}
>
<For each={field().options}>
{(option, index) => (
<RadioItem value={String(index())} label={option.label} description={option.description} />
)}
</For>
<RadioItem value="custom" label={language.t("ui.messagePart.option.typeOwnAnswer")} />
<Show when={custom()}>
<div class="pl-6">
<TextField
ref={(input: HTMLInputElement) => queueMicrotask(() => input.focus())}
type="text"
label={language.t("ui.messagePart.option.typeOwnAnswer")}
hideLabel
placeholder={field().placeholder ?? language.t("ui.question.custom.placeholder")}
value={answer(field()) ?? ""}
onChange={(value) => setFormStore("value", field().key, value)}
/>
</div>
</Show>
</RadioGroup>
</div>
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</>
)}
</Match>
<Match when={item()?.field.options !== undefined}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div>
@@ -758,7 +854,7 @@ function ProviderConnection(props: {
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
current={select()?.options?.find((x) => x.value === answer(select()!))}
onSelect={(value) => {
if (!value) return
const field = select()
@@ -876,9 +972,11 @@ function ProviderConnection(props: {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -890,16 +988,16 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = typeof value === "string" ? value : ""
const key = value instanceof File || value === null ? "" : value
if (!apiKey?.trim()) {
if (!key.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await controller.auth.connectKey(apiKey)
await controller.auth.connectKey(key)
}
return (
@@ -954,9 +1052,11 @@ function ProviderConnection(props: {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -968,9 +1068,9 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = typeof value === "string" ? value : ""
const code = value instanceof File || value === null ? "" : value
if (!code?.trim()) {
if (!code.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
+19 -30
View File
@@ -1,7 +1,5 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useFilteredList } from "@opencode/ui/hooks"
@@ -24,7 +22,7 @@ export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
const [store, setStore] = createStore<{ collapsed: Record<string, boolean> }>({ collapsed: {} })
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
@@ -107,33 +105,24 @@ export const DialogManageModels: Component = () => {
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
showClearButton={!!list.filter()}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => list.clear()}
/>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
@@ -0,0 +1,38 @@
[data-slot="mobile-drawer-content"]:has([data-slot="model-selector-drawer"]) {
padding-left: 0;
padding-right: 0;
}
[data-slot="model-selector-drawer"] {
/* Keep the gutter inside each scroll boundary so outlines are not clipped. */
[data-slot="model-selector-search"],
.settings-models,
[data-slot="model-selector-actions"] {
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
}
[data-component="settings-list"][data-variant="catalog"] {
padding-inline: 12px;
}
[data-component="settings-row"] {
width: calc(100% + 24px);
margin-inline: -12px;
padding-inline: 12px;
}
.settings-models-console-groups {
margin-inline-start: 4px;
padding-inline-start: 8px;
}
.provider-model-group-trigger,
.provider-model-group-models {
padding-inline: 12px;
}
.provider-model-group-models > [data-component="settings-list"] {
padding-inline: 0;
}
}
@@ -1,6 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show, Suspense, lazy, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
@@ -8,7 +9,6 @@ import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
@@ -24,8 +24,20 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
import {
CONSOLE_GROUP_KEY,
consoleModelGroup,
ProviderModelIcon,
ProviderModelSections,
} from "@/providers/models/provider-group"
import "@/settings/settings.css"
import "./select-dialog.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -54,6 +66,8 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
}
const ModelList: Component<{
mobile?: boolean
open?: boolean
provider?: string
onSelect: () => void
model?: ModelState
@@ -66,12 +80,21 @@ const ModelList: Component<{
onSelect: props.onSelect,
})
const [store, setStore] = createStore({
const [store, setStore] = createStore<{ search: string; active: string; collapsed: Record<string, boolean> }>({
search: "",
active: "",
collapsed: {} as Record<string, boolean>,
active: props.mobile ? (controller.current() ?? "") : "",
collapsed: {},
})
createEffect(
on(
() => props.open,
(open) => {
if (!props.mobile || !open) return
setStore({ search: "", active: controller.current() ?? "" })
},
),
)
const models = createMemo(() => controller.models(store.search))
const modelGroups = createMemo(() => controller.groups(models()))
const managed = createMemo(() => consoleModelGroup(controller.all()))
@@ -110,7 +133,7 @@ const ModelList: Component<{
if (item) controller.select(item)
}
function ModelRows(props: { items: ModelItem[] }) {
function ModelRows(props: { items: ModelItem[]; mobile?: boolean }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
@@ -119,6 +142,7 @@ const ModelList: Component<{
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
aria-pressed={controller.current() === modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
@@ -128,6 +152,7 @@ const ModelList: Component<{
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
inactive={props.mobile}
placement="right-start"
gutter={12}
openDelay={0}
@@ -159,59 +184,54 @@ const ModelList: Component<{
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
<div data-slot="model-selector-search" class="shrink-0 pt-px pb-3" classList={{ "px-4": !props.mobile }}>
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus={!props.mobile}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={store.search}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
showClearButton={!!store.search}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => setSearch("")}
/>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<div class="relative min-h-0" classList={{ "flex-1": !props.mobile }}>
<div
ref={(element) => (scrollRef = element)}
class="settings-panel settings-models pt-1 pb-4"
classList={{ "h-full px-4": !props.mobile, "max-h-[min(360px,40dvh)]": props.mobile }}
>
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
@@ -222,7 +242,7 @@ const ModelList: Component<{
expanded={expanded}
disabled={store.search.length > 0}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
rows={(items) => <ModelRows items={items} />}
rows={(items) => <ModelRows items={items} mobile={props.mobile} />}
/>
</Show>
</div>
@@ -238,10 +258,12 @@ type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
unpaid?: boolean
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const dialog = useDialog()
const mobile = createMediaQuery("(max-width: 767px)")
const data = useData()
const location = useWorkspaceLocation()
@@ -256,27 +278,171 @@ export function ModelSelectorPopover(props: {
const connection = data.location.integration
.list(location().ref)
?.find((integration) => integration.id === "openai")
?.connections[0]
?.find((integration) => integration.id === "openai")?.connections[0]
return connection?.type === "credential" && connection.method === "oauth"
}
const manage = async () => {
const { DialogManageModels } = await import("./manage")
void dialog.show(() => <DialogManageModels />)
}
const connect = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={location().directory} />)
}
return (
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
void dialog.show(() => <module.DialogManageModels />)
})
}}
onClose={() => props.onClose?.()}
/>
<Show
when={mobile()}
fallback={
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={manage}
onClose={() => props.onClose?.()}
/>
}
>
<ModelSelectorDrawer
trigger={props.trigger}
model={props.model}
provider={props.provider}
onConnect={props.unpaid ? connect : undefined}
chatgptPlan={chatgptPlan()}
onClose={() => props.onClose?.()}
onManage={manage}
/>
</Show>
)
}
function ModelSelectorDrawer(props: {
trigger: ModelSelectorTrigger
model?: ModelState
provider?: string
chatgptPlan?: boolean
onClose: () => void
onManage: () => void
onConnect?: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore<{
open: boolean
loaded: boolean
restoreTrigger: boolean
action?: "select" | "manage" | "connect"
}>({
open: false,
loaded: false,
restoreTrigger: true,
})
let trigger: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
return (
<>
<div ref={trigger} class="min-w-0">
{props.trigger({
"aria-haspopup": "dialog",
get "aria-expanded"() {
return store.open
},
onClick: () => setStore({ open: true, loaded: true, restoreTrigger: true }),
})}
</div>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={language.t("dialog.model.select.title")}
open={store.open}
onOpenChange={(open) => setStore("open", open)}
initialFocus={() => content}
returnFocus={() => trigger?.querySelector("button") ?? undefined}
onFinalFocus={(event) => {
if (!store.restoreTrigger) event.preventDefault()
}}
onContentPresentChange={(present) => {
if (present) return
const action = store.action
setStore("action", undefined)
if (action === "manage") {
props.onManage()
return
}
if (action === "connect") {
props.onConnect?.()
return
}
if (action === "select") queueMicrotask(props.onClose)
}}
>
<div
ref={content}
tabIndex={-1}
data-slot="model-selector-drawer"
class="flex min-h-0 flex-col gap-2 outline-none"
>
<div class="flex min-h-0 flex-col">
<ModelList
mobile
open={store.open}
model={props.model}
provider={props.provider}
onSelect={() => setStore({ open: false, action: "select", restoreTrigger: false })}
/>
</div>
<div data-slot="model-selector-actions" class="flex flex-col gap-2">
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="outline-sliders"
onClick={() => setStore({ open: false, action: "manage", restoreTrigger: false })}
>
{language.t("dialog.model.manage")}
</Button>
<Show when={props.onConnect}>
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="plus"
onClick={() => setStore({ open: false, action: "connect", restoreTrigger: false })}
>
{language.t("command.provider.connect")}
</Button>
</Show>
<Show when={props.chatgptPlan}>
<div class="flex min-h-10 items-center gap-2 border-t border-v2-border-border-muted px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
<ExternalLink
href="https://chatgpt.com/settings/usage"
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("dialog.model.chatgptManageUsage")}
<Icon name="arrow-up-right" size="small" />
</ExternalLink>
</div>
</Show>
</div>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</>
)
}
@@ -437,7 +603,7 @@ export function ModelSelectorPopoverView(props: {
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => (searchRef = el)}
@@ -487,12 +653,12 @@ export function ModelSelectorPopoverView(props: {
<Show when={store.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
>
<Icon name="close" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
@@ -69,6 +69,7 @@ type Attached = {
keybind: (command: string) => readonly string[]
matches: (command: string, event: KeyboardEvent) => boolean
servers: Accessor<readonly string[]>
server: (id: string) => ServerRef | undefined
}
/**
@@ -269,7 +270,10 @@ export function createHostApis() {
keys: (bind) => formatKeybindParts(bind, language.t),
matches: (command, event) => current()?.matches(command, event) ?? false,
}),
servers: () => ({ list: () => current()?.servers() ?? [] }),
servers: () => ({
list: () => current()?.servers() ?? [],
get: (id) => current()?.server(id),
}),
workspaces: (_extension, _owner, _context, register) => ({
on(_event, handler) {
removed.add(handler)
@@ -389,15 +393,28 @@ export function createExtensionAttachment(apis: HostApis) {
if (existing) return existing
const key = ServerConnection.Key.make(id)
// The controller starts on first use, not when the ref is made: reading `builtin` to find a server must not
// connect to every listed server, such as one the runtime holds back because it rejects its credentials.
let started: Accessor<ServerCtx> | undefined
const live = runWithOwner(owner, () =>
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
)!
const live = () => {
started ??= runWithOwner(owner, () =>
createMemo<ServerCtx>(
(previous) => global.serverCtx(key) ?? previous,
global.ensureServerCtx(connection(id) ?? conn),
),
)!
return started()
}
// What the app's server list already says, without a controller.
const listed = () => connection(id) ?? live().sdk.server
const ref: ServerRef = {
id,
get name() {
return serverName(live().sdk.server) || id
return serverName(listed()) || id
},
get url() {
return live().sdk.url
@@ -412,10 +429,10 @@ export function createExtensionAttachment(apis: HostApis) {
return live().data
},
get local() {
return ServerConnection.local(live().sdk.server)
return ServerConnection.local(listed())
},
get builtin() {
return ServerConnection.builtin(live().sdk.server)
return ServerConnection.builtin(listed())
},
get compatible() {
return !global.servers.health[key]?.incompatible
@@ -783,6 +800,7 @@ export function createExtensionAttachment(apis: HostApis) {
keybind: command.keybindParts,
matches: command.matches,
servers: () => global.servers.list().map(ServerConnection.key),
server,
// SAFETY: an extension names a page it contributed through `SettingsPage`, which settings lists as an extension tab.
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
layout: {
+91 -75
View File
@@ -2,10 +2,7 @@ import { createMemo, For, lazy, Show, Suspense, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import {
DrawerContext,
Panel,
type PanelSidebar,
type PanelTab,
@@ -57,7 +54,7 @@ export function createMobileViews() {
export type MobileViews = ReturnType<typeof createMobileViews>
/** The narrow-screen view switcher: the conversation, each panel's view, and an overflow menu. */
/** The narrow-screen view switcher: the conversation, each tab view, and a More drawer with the other views. */
export function MobileViewTabs(props: {
views: MobileViews
region: Region
@@ -65,34 +62,38 @@ export function MobileViewTabs(props: {
session: MountedSession
screen: SessionScreen
sidebar: PanelSidebar
bottom?: boolean
onSelect: (key: string) => void
}): JSX.Element {
const language = useLanguage()
const [store, setStore] = createStore<{
menu: boolean
drawer: string | undefined
last: string | undefined
open: boolean
loaded: boolean
pending: string | undefined
drawer: string | undefined
}>({
menu: false,
drawer: undefined,
// Keeps the last drawer's content mounted while it animates closed.
last: undefined,
open: false,
loaded: false,
pending: undefined,
drawer: undefined,
})
const drawer = createMemo(() => (store.last ? props.views.find(store.last) : undefined))
const drawer = createMemo(() => (store.drawer ? props.views.find(store.drawer) : undefined))
const tab = createMemo(() =>
props.current === "session" || props.views.tabs().some((entry) => entry.key === props.current)
? props.current
: "more",
)
let trigger: HTMLButtonElement | undefined
return (
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:h-px before:bg-v2-border-border-base before:content-['']"
classList={{ "before:top-0": props.bottom, "before:bottom-0": !props.bottom }}
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs value={tab()} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<Tabs.Trigger
value="session"
@@ -114,73 +115,88 @@ export function MobileViewTabs(props: {
</Tabs.Trigger>
)}
</For>
<Show when={props.views.menu().length}>
<Tabs.Trigger
value="more"
ref={(element: HTMLButtonElement) => {
trigger = element
}}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setStore({ open: true, loaded: true, drawer: undefined })}
>
{language.t("session.tab.more")}
</Tabs.Trigger>
</Show>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.views.menu().some((entry) => entry.key === props.current) || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
setStore({ drawer: store.pending, last: store.pending, loaded: true, pending: undefined })
}}
>
<For each={props.views.menu()}>
{(entry) => (
<Menu.Item
onSelect={() => {
if (entry.mobile.kind === "menu") return props.onSelect(entry.key)
setStore({ pending: entry.key, menu: false })
}}
>
{entry.mobile.title}
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
title={drawer()?.mobile.title ?? ""}
open={!!store.drawer}
onOpenChange={(open) => {
if (!open) setStore("drawer", undefined)
}}
title={drawer()?.mobile.title ?? language.t("common.moreOptions")}
hideHeader
open={store.open}
onOpenChange={(open) => setStore("open", open)}
returnFocus={() => trigger}
>
<Show when={drawer()} keyed>
<Show
when={drawer()}
keyed
fallback={
<div
data-slot="session-mobile-view-options"
class="flex flex-col gap-0.5 rounded-[6px] bg-v2-background-bg-base p-0.5 shadow-[var(--v2-elevation-raised)] [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "menu")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)] aria-pressed:bg-[var(--v2-overlay-simple-overlay-pressed)]"
aria-pressed={props.current === entry.key}
onClick={() => {
props.onSelect(entry.key)
setStore("open", false)
}}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Show when={props.current === entry.key}>
<Icon name="check" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "drawer")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)]"
onClick={() => setStore("drawer", entry.key)}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Icon name="chevron-right" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</button>
)}
</For>
</div>
}
>
{(entry) => (
<DrawerContext.Provider value={{ close: () => setStore("drawer", undefined) }}>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.drawer === entry.key}
open={() => props.region.openFor(entry.extension)}
/>
</DrawerContext.Provider>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.open}
open={() => props.region.openFor(entry.extension)}
/>
)}
</Show>
</MobilePanelDrawer>
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
"dialog.server.edit.title": "አገልጋይ አርትዕ",
"dialog.server.menu.edit": "አርትዕ",
"dialog.server.menu.default": "ነባሪ አዘጋጅ",
"dialog.server.menu.defaultRemove": "ነባሪውን አስወግድ",
"dialog.server.status.default": "ነባሪ",
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
"dialog.project.edit.icon": "አዶ",
"dialog.project.edit.icon.alt": "የፕሮጀክት አዶ",
@@ -775,14 +772,12 @@ export const dict = {
"server.connect.button": "መገናኘት",
"server.connect.address.invalid": "የሚሰራ የኤችቲቲፒ ወይም የኤችቲቲፒኤስ አገልጋይ አድራሻ ያስገቡ።",
"server.connect.failed": "መገናኘት አልተቻለም። የአገልጋዩን አድራሻ እና የይለፍ ቃል ያረጋግጡ እና ከዚያ እንደገና ይሞክሩ።",
"server.connect.pair.description": "የግንኙነት ዝርዝሮችዎን ለማግኘት ይህንን ትዕዛዝ በኮምፒተርዎ ላይ ያሂዱ።",
"server.connect.scan": "QR ኮድ ይቃኙ",
"server.connect.scan.description": "ካሜራዎን opencode pair ወደሚያሳየው QR ኮድ ያነጣጥሩ።",
"server.connect.scan.invalid": "ይህ የOpenCode ማጣመሪያ ኮድ አይደለም። opencode pair የሚያሳየውን ኮድ ይቃኙ።",
"server.connect.camera": "ማጣመር ካሜራ",
"server.connect.camera.starting": "ካሜራ በመክፈት ላይ...",
"server.connect.mixedContent": "ከዚህ ኤችቲቲፒኤስ አገልጋይ ጋር ከኤችቲቲፒኤስ ገጽ ጋር መገናኘት አልተቻለም። በምትኩ የኤችቲቲፒኤስ አገልጋይ አድራሻ ይጠቀሙ።",
"server.connect.camera.insecure": "QR ቅኝት ይህንን ገጽ በኤችቲቲፒኤስ ወይም በአካባቢያዊ አስተናጋጅ መክፈት ይጠይቃል።",
"server.connect.camera.unavailable": "ለዚህ አሳሽ ምንም ካሜራ አይገኝም። የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"server.connect.camera.error": "ካሜራውን መክፈት አልተቻለም። የካሜራ መዳረሻ ይፍቀዱ ወይም የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"dialog.server.menu.remove": "አስወግድ",
-5
View File
@@ -297,9 +297,6 @@ export const dict = {
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.menu.edit": "تعديل",
"dialog.server.menu.default": "تعيين كافتراضي",
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
"dialog.server.status.default": "افتراضي",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.icon": "أيقونة",
"dialog.project.edit.icon.alt": "أيقونة المشروع",
@@ -833,14 +830,12 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "أدخل عنوان خادم HTTP أو HTTPS صالحًا.",
"server.connect.failed": "تعذر الاتصال. تحقق من عنوان الخادم وكلمة المرور، ثم أعد المحاولة.",
"server.connect.pair.description": "نفّذ هذا الأمر على حاسوبك للحصول على تفاصيل الاتصال.",
"server.connect.scan": "مسح رمز QR",
"server.connect.scan.description": "وجّه الكاميرا نحو رمز QR الذي يعرضه opencode pair.",
"server.connect.scan.invalid": "هذا ليس رمز إقران OpenCode. امسح الرمز الذي يعرضه opencode pair.",
"server.connect.camera": "كاميرا الإقران",
"server.connect.camera.starting": "جارٍ فتح الكاميرا…",
"server.connect.mixedContent": "تعذر الاتصال بخادم HTTP هذا من صفحة HTTPS. استخدم عنوان خادم HTTPS بدلًا منه.",
"server.connect.camera.insecure": "يتطلب مسح رمز QR فتح هذه الصفحة عبر HTTPS أو على localhost.",
"server.connect.camera.unavailable": "لا تتوفر كاميرا لهذا المتصفح. أدخل تفاصيل الاتصال يدويًا.",
"server.connect.camera.error": "تعذر فتح الكاميرا. اسمح بالوصول إليها أو أدخل تفاصيل الاتصال يدويًا.",
"dialog.server.menu.remove": "إزالة",
-6
View File
@@ -288,9 +288,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "parol",
"dialog.server.edit.title": "Serveri redaktə et",
"dialog.server.menu.edit": "Redaktə et",
"dialog.server.menu.default": "Standart olaraq təyin et",
"dialog.server.menu.defaultRemove": "Standartı sil",
"dialog.server.status.default": "Standart",
"dialog.project.edit.title": "Layihəni redaktə et",
"dialog.project.edit.icon": "İkon",
"dialog.project.edit.icon.alt": "Layihə ikonu",
@@ -799,7 +796,6 @@ export const dict = {
"server.connect.button": "Qoşulun",
"server.connect.address.invalid": "Etibarlı HTTP və ya HTTPS server ünvanını daxil edin.",
"server.connect.failed": "Bağlana bilmədik. Server ünvanını və şifrəni yoxlayın, sonra yenidən cəhd edin.",
"server.connect.pair.description": "Bağlantı məlumatlarınızı almaq üçün bu əmri kompüterinizdə işlədin.",
"server.connect.scan": "QR kodu skan edin",
"server.connect.scan.description": "Kameranızı opencode pair tərəfindən göstərilən QR koda yönəldin.",
"server.connect.scan.invalid": "Bu, OpenCode qoşulma kodu deyil. opencode pair tərəfindən göstərilən kodu skan edin.",
@@ -807,8 +803,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera açılır…",
"server.connect.mixedContent":
"Bu HTTPS səhifəsindən bu HTTP serverinə qoşulmaq mümkün olmadı. Bunun əvəzinə HTTPS server ünvanından istifadə edin.",
"server.connect.camera.insecure":
"QR skan edilməsi üçün bu səhifəni HTTPS üzərindən və ya localhost-da açmaq tələb olunur.",
"server.connect.camera.unavailable":
"Bu brauzer üçün heç bir kamera mövcud deyil. Bağlantı məlumatlarınızı əl ilə daxil edin.",
"server.connect.camera.error":
-6
View File
@@ -287,9 +287,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "парола",
"dialog.server.edit.title": "Редактиране на сървъра",
"dialog.server.menu.edit": "Редактиране",
"dialog.server.menu.default": "Задайте по подразбиране",
"dialog.server.menu.defaultRemove": "Премахване на подразбиране",
"dialog.server.status.default": "По подразбиране",
"dialog.project.edit.title": "Редактиране на проекта",
"dialog.project.edit.icon": "Икона",
"dialog.project.edit.icon.alt": "Икона на проекта",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "Свързване",
"server.connect.address.invalid": "Въведете валиден HTTP или HTTPS адрес на сървър.",
"server.connect.failed": "Свързването е неуспешно. Проверете адреса и паролата на сървъра и опитайте отново.",
"server.connect.pair.description": "Изпълнете тази команда на компютъра си, за да получите данните за връзка.",
"server.connect.scan": "Сканиране на QR код",
"server.connect.scan.description": "Насочете камерата към QR кода, показан от opencode pair.",
"server.connect.scan.invalid": "Това не е код за сдвояване с OpenCode. Сканирайте кода, показан от opencode pair.",
@@ -805,8 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Отваряне на камерата…",
"server.connect.mixedContent":
"Не може да се осъществи връзка с този HTTP сървър от HTTPS страница. Използвайте HTTPS адрес на сървъра.",
"server.connect.camera.insecure":
"Сканирането на QR код изисква тази страница да е отворена чрез HTTPS или на localhost.",
"server.connect.camera.unavailable": "Няма достъпна камера за този браузър. Въведете ръчно данните за връзка.",
"server.connect.camera.error":
"Камерата не можа да бъде отворена. Разрешете достъпа до нея или въведете ръчно данните за връзка.",
-5
View File
@@ -290,9 +290,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
"dialog.server.menu.edit": "সম্পাদনা করুন",
"dialog.server.menu.default": "ডিফল্ট হিসেবে সেট করুন",
"dialog.server.menu.defaultRemove": "ডিফল্ট সরান",
"dialog.server.status.default": "ডিফল্ট",
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
"dialog.project.edit.icon": "আইকন",
"dialog.project.edit.icon.alt": "প্রকল্প আইকন",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "সংযোগ করুন",
"server.connect.address.invalid": "একটি বৈধ HTTP বা HTTPS সার্ভার ঠিকানা লিখুন৷",
"server.connect.failed": "সংযোগ করা যায়নি। সার্ভার ঠিকানা এবং পাসওয়ার্ড পরীক্ষা করুন, তারপর আবার চেষ্টা করুন.",
"server.connect.pair.description": "আপনার সংযোগের বিবরণ পেতে আপনার কম্পিউটারে এই কমান্ডটি চালান।",
"server.connect.scan": "QR কোড স্ক্যান করুন",
"server.connect.scan.description": "আপনার ক্যামেরাকে ওপেনকোড পেয়ার দ্বারা দেখানো QR কোডে নির্দেশ করুন৷",
"server.connect.scan.invalid": "এটি একটি OpenCode পেয়ারিং কোড নয়৷ ওপেনকোড পেয়ার দ্বারা দেখানো কোডটি স্ক্যান করুন।",
@@ -805,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "ক্যামেরা খোলা হচ্ছে...",
"server.connect.mixedContent":
"একটি HTTPS পৃষ্ঠা থেকে এই HTTP সার্ভারের সাথে সংযোগ করা যায়নি৷ পরিবর্তে একটি HTTPS সার্ভার ঠিকানা ব্যবহার করুন৷",
"server.connect.camera.insecure": "QR স্ক্যান করার জন্য এই পৃষ্ঠাটি HTTPS বা লোকালহোস্টে খুলতে হবে।",
"server.connect.camera.unavailable": "এই ব্রাউজারে কোন ক্যামেরা উপলব্ধ নেই৷ ম্যানুয়ালি আপনার সংযোগ বিশদ লিখুন.",
"server.connect.camera.error":
"ক্যামেরা খুলতে পারিনি। ক্যামেরা অ্যাক্সেসের অনুমতি দিন বা ম্যানুয়ালি আপনার সংযোগের বিবরণ লিখুন।",
-5
View File
@@ -22,7 +22,6 @@ export const dict = {
"server.connect.button": "Conectar",
"server.connect.address.invalid": "Insira um endereço de servidor HTTP ou HTTPS válido.",
"server.connect.failed": "Não foi possível conectar. Verifique o endereço e a senha do servidor e tente novamente.",
"server.connect.pair.description": "Execute este comando em seu computador para obter os detalhes de sua conexão.",
"server.connect.scan": "Digitalize o código QR",
"server.connect.scan.description": "Aponte sua câmera para o código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Abrindo a câmera…",
"server.connect.mixedContent":
"Não foi possível conectar-se a este servidor HTTP a partir de uma página HTTPS. Use um endereço de servidor HTTPS.",
"server.connect.camera.insecure": "A varredura QR requer a abertura desta página em HTTPS ou no host local.",
"server.connect.camera.unavailable":
"Nenhuma câmera está disponível para este navegador. Insira os detalhes da sua conexão manualmente.",
"server.connect.camera.error":
@@ -510,9 +508,6 @@ export const dict = {
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Definir como padrão",
"dialog.server.menu.defaultRemove": "Remover padrão",
"dialog.server.status.default": "Padrão",
"dialog.project.edit.title": "Editar projeto",
"dialog.project.edit.icon": "Ícone",
"dialog.project.edit.icon.alt": "Ícone do projeto",
-6
View File
@@ -312,9 +312,6 @@ export const dict = {
"dialog.server.edit.title": "Uredi server",
"dialog.server.menu.edit": "Uredi",
"dialog.server.menu.default": "Postavi kao podrazumijevano",
"dialog.server.menu.defaultRemove": "Ukloni podrazumijevano",
"dialog.server.status.default": "Podrazumijevano",
"dialog.project.edit.title": "Uredi projekat",
"dialog.project.edit.icon": "Ikonica",
@@ -862,7 +859,6 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu servera.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu servera i lozinku, pa pokušajte ponovo.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računaru da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -870,8 +866,6 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP serverom s HTTPS stranice. Umjesto toga koristite HTTPS adresu servera.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localhostu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
-6
View File
@@ -286,9 +286,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "contrasenya",
"dialog.server.edit.title": "Edita el servidor",
"dialog.server.menu.edit": "Edita",
"dialog.server.menu.default": "Estableix com a predeterminat",
"dialog.server.menu.defaultRemove": "Elimina el valor predeterminat",
"dialog.server.status.default": "Per defecte",
"dialog.project.edit.title": "Edita el projecte",
"dialog.project.edit.icon": "Icona",
"dialog.project.edit.icon.alt": "Icona del projecte",
@@ -796,8 +793,6 @@ export const dict = {
"server.connect.address.invalid": "Introduïu una adreça de servidor HTTP o HTTPS vàlida.",
"server.connect.failed":
"No s'ha pogut connectar. Comproveu l'adreça i la contrasenya del servidor i torneu-ho a provar.",
"server.connect.pair.description":
"Executeu aquesta ordre al vostre ordinador per obtenir els detalls de la vostra connexió.",
"server.connect.scan": "Escaneja el codi QR",
"server.connect.scan.description": "Apunteu la càmera al codi QR que mostra opencode pair.",
"server.connect.scan.invalid":
@@ -806,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Obertura de la càmera...",
"server.connect.mixedContent":
"No s'ha pogut connectar a aquest servidor HTTP des d'una pàgina HTTPS. Utilitzeu una adreça de servidor HTTPS.",
"server.connect.camera.insecure": "L'exploració de QR requereix obrir aquesta pàgina a HTTPS o a localhost.",
"server.connect.camera.unavailable":
"No hi ha cap càmera disponible per a aquest navegador. Introduïu els detalls de connexió manualment.",
"server.connect.camera.error":
-5
View File
@@ -286,9 +286,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "heslo",
"dialog.server.edit.title": "Upravit server",
"dialog.server.menu.edit": "Upravit",
"dialog.server.menu.default": "Nastavit jako výchozí",
"dialog.server.menu.defaultRemove": "Odebrat výchozí",
"dialog.server.status.default": "Výchozí",
"dialog.project.edit.title": "Upravit projekt",
"dialog.project.edit.icon": "ikona",
"dialog.project.edit.icon.alt": "Ikona projektu",
@@ -806,7 +803,6 @@ export const dict = {
"server.connect.button": "Připojit",
"server.connect.address.invalid": "Zadejte platnou adresu serveru HTTP nebo HTTPS.",
"server.connect.failed": "Připojení se nezdařilo. Zkontrolujte adresu serveru a heslo a zkuste to znovu.",
"server.connect.pair.description": "Spuštěním tohoto příkazu v počítači získáte údaje pro připojení.",
"server.connect.scan": "Naskenovat QR kód",
"server.connect.scan.description": "Namiřte kameru na QR kód zobrazený příkazem opencode pair.",
"server.connect.scan.invalid": "Toto není párovací kód OpenCode. Naskenujte kód zobrazený příkazem opencode pair.",
@@ -814,7 +810,6 @@ export const dict = {
"server.connect.camera.starting": "Otevírání kamery…",
"server.connect.mixedContent":
"K tomuto serveru HTTP se nelze připojit ze stránky HTTPS. Použijte adresu serveru HTTPS.",
"server.connect.camera.insecure": "Skenování QR kódu vyžaduje otevření této stránky přes HTTPS nebo na localhostu.",
"server.connect.camera.unavailable":
"Pro tento prohlížeč není dostupná žádná kamera. Zadejte údaje pro připojení ručně.",
"server.connect.camera.error":
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Indtast en gyldig HTTP- eller HTTPS-serveradresse.",
"server.connect.failed":
"Kunne ikke oprette forbindelse. Tjek serveradressen og adgangskoden, og prøv derefter igen.",
"server.connect.pair.description": "Kør denne kommando på din computer for at få dine forbindelsesdetaljer.",
"server.connect.scan": "Scan QR kode",
"server.connect.scan.description": "Ret dit kamera mod QR-koden vist af opencode pair.",
"server.connect.scan.invalid": "Dette er ikke en OpenCode-parringskode. Scan koden vist af opencode pair.",
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Åbner kamera...",
"server.connect.mixedContent":
"Kunne ikke oprette forbindelse til denne HTTP-server fra en HTTPS-side. Brug i stedet en HTTPS-serveradresse.",
"server.connect.camera.insecure": "QR-scanning kræver, at denne side åbnes over HTTPS eller på localhost.",
"server.connect.camera.unavailable":
"Intet kamera er tilgængeligt for denne browser. Indtast dine forbindelsesoplysninger manuelt.",
"server.connect.camera.error":
@@ -448,9 +446,6 @@ export const dict = {
"dialog.server.edit.title": "Rediger server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sæt som standard",
"dialog.server.menu.defaultRemove": "Fjern som standard",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger projekt",
"dialog.project.edit.icon": "Ikon",
-7
View File
@@ -27,8 +27,6 @@ export const dict = {
"server.connect.address.invalid": "Geben Sie eine gültige HTTP- oder HTTPS-Serveradresse ein.",
"server.connect.failed":
"Es konnte keine Verbindung hergestellt werden. Überprüfen Sie die Serveradresse und das Passwort und versuchen Sie es dann erneut.",
"server.connect.pair.description":
"Führen Sie diesen Befehl auf Ihrem Computer aus, um Ihre Verbindungsdetails abzurufen.",
"server.connect.scan": "Scannen Sie den QR-Code",
"server.connect.scan.description": "Richten Sie Ihre Kamera auf den von opencode pair angezeigten Code QR.",
"server.connect.scan.invalid":
@@ -37,8 +35,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera wird geöffnet…",
"server.connect.mixedContent":
"Es konnte keine Verbindung zu diesem HTTP-Server von einer HTTPS-Seite hergestellt werden. Verwenden Sie stattdessen eine HTTPS-Serveradresse.",
"server.connect.camera.insecure":
"Zum Scannen mit QR muss diese Seite über HTTPS oder auf localhost geöffnet werden.",
"server.connect.camera.unavailable":
"Für diesen Browser ist keine Kamera verfügbar. Geben Sie Ihre Verbindungsdaten manuell ein.",
"server.connect.camera.error":
@@ -446,9 +442,6 @@ export const dict = {
"dialog.server.add.password": "Passwort (optional)",
"dialog.server.edit.title": "Server bearbeiten",
"dialog.server.menu.edit": "Bearbeiten",
"dialog.server.menu.default": "Als Standard festlegen",
"dialog.server.menu.defaultRemove": "Standard entfernen",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Projekt bearbeiten",
"dialog.project.edit.icon": "Symbol",
"dialog.project.edit.icon.alt": "Projektsymbol",
-6
View File
@@ -292,9 +292,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "ޕާސްވަރޑް",
"dialog.server.edit.title": "އެޑިޓް ސަރވަރ",
"dialog.server.menu.edit": "ބަދަލު ގެނައުން",
"dialog.server.menu.default": "ޑިފޯލްޓް ގޮތުގައި ސެޓްކުރުން",
"dialog.server.menu.defaultRemove": "ޑިފޯލްޓް ނައްތާލާށެވެ",
"dialog.server.status.default": "ޑީފޯލްޓް",
"dialog.project.edit.title": "އެޑިޓް ޕްރޮޖެކްޓް",
"dialog.project.edit.icon": "އައިކޮން",
"dialog.project.edit.icon.alt": "ޕްރޮޖެކްޓް އައިކޮން",
@@ -816,7 +813,6 @@ export const dict = {
"server.connect.address.invalid": "ސައްހަ HTTP ނުވަތަ HTTPS ސަރވަރ އެޑްރެހެއް ލިޔެލާށެވެ.",
"server.connect.failed":
"ގުޅެވޭ ގޮތެއް ނުވިއެވެ. ސަރވަރ އެޑްރެހާއި ޕާސްވޯޑް ޗެކްކޮށްލުމަށްފަހު އަލުން މަސައްކަތް ކުރާށެވެ.",
"server.connect.pair.description": "ކަނެކްޝަންގެ ތަފްސީލު ހޯދުމަށްޓަކައި ކޮމްޕިއުޓަރުގައި މި ކޮމާންޑް ހިންގާށެވެ.",
"server.connect.scan": "QR ކޯޑް ސްކޭން ކުރާށެވެ",
"server.connect.scan.description": "އޮޕަންކޯޑް ޖޯޑުން ދައްކާ QR ކޯޑަށް ކެމެރާ އިޝާރާތް ކުރާށެވެ.",
"server.connect.scan.invalid": "މިއީ OpenCode ޖޯޑު ކޯޑެއް ނޫނެވެ. އޮޕަންކޯޑް ޕެއާ އިން ދައްކާ ކޯޑް ސްކޭން ކުރާށެވެ.",
@@ -824,8 +820,6 @@ export const dict = {
"server.connect.camera.starting": "ކެމެރާ ހުޅުވަނީ...",
"server.connect.mixedContent":
"HTTPS ޕޭޖަކުން މި HTTP ސަރވަރ އާއި ގުޅޭ ގޮތެއް ނުވިއެވެ. އޭގެ ބަދަލުގައި HTTPS ސަރވަރ އެޑްރެހެއް ބޭނުން ކުރާށެވެ.",
"server.connect.camera.insecure":
"QR ސްކޭން ކުރުމަށް މި ޕޭޖް ހުޅުވަން ޖެހެނީ HTTPS ގެ މައްޗަށް ނުވަތަ ލޯކަލްހޯސްޓް އިންނެވެ.",
"server.connect.camera.unavailable":
"މި ބްރައުޒާއަށް އެއްވެސް ކެމެރާއެއް ނުލިބެއެވެ. ކަނެކްޝަންގެ ތަފްސީލުތައް އަމިއްލައަށް ލިޔެލާށެވެ.",
"server.connect.camera.error":
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "གསང༌ཨང",
"dialog.server.edit.title": "སར་བར་ཞུན་དག་འབད།",
"dialog.server.menu.edit": "ཞུན༌དག",
"dialog.server.menu.default": "སྔོན་སྒྲིག་སྦེ་གཞི་སྒྲིག་འབད།",
"dialog.server.menu.defaultRemove": "སྔོན་སྒྲིག་རྩ་བསྐྲད་གཏང་།",
"dialog.server.status.default": "འཐུས་ཤོར",
"dialog.project.edit.title": "ཞུན་དག་ལས་གཞི།",
"dialog.project.edit.icon": "ངོས་དཔར།",
"dialog.project.edit.icon.alt": "ལས་འགུལ་ངོས་དཔར།",
@@ -797,8 +794,6 @@ export const dict = {
"server.connect.button": "མཐུད།",
"server.connect.address.invalid": "ནུས་ཅན་ HTTP ཡང་ན་ HTTPS སར་བར་ཁ་བྱང་ཅིག་བཙུགས།",
"server.connect.failed": "མཐུད་མ་ཚུགས། སར་བར་ཁ་བྱང་དང་ཆོག་ཡིག་ཞིབ་དཔྱད་འབད་ཞིནམ་ལས་ ལོག་སྟེ་འབད་རྩོལ་བསྐྱེད།",
"server.connect.pair.description":
"ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ཐོབ་ནིའི་དོན་ལུ་ ཁྱོད་རའི་གློག་རིག་གུ་བརྡ་བཀོད་འདི་གཡོག་བཀོལ།",
"server.connect.scan": "QR གསང་གྲངས་པར་བཤུས་",
"server.connect.scan.description": "ཁྱོད་རའི་པར་ཆས་འདི་ opencode pair གིས་སྟོན་མི་ QR གསང་ཡིག་ལུ་སྟོན་དགོ།",
"server.connect.scan.invalid":
@@ -807,7 +802,6 @@ export const dict = {
"server.connect.camera.starting": "པར་ཆས་ཁ་ཕྱེ་དོ...",
"server.connect.mixedContent":
"HTTPS ཤོག་ལེབ་ཅིག་ལས་ HTTP སར་བར་འདི་ལུ་མཐུད་མ་ཚུགས། དེ་གི་ཚབ་ལུ་ HTTPS སར་བར་ཁ་བྱང་ཅིག་ལག་ལེན་འཐབ།",
"server.connect.camera.insecure": "QR པར་ལེན་འབད་ནི་ལུ་ ཤོག་ལེབ་འདི་ HTTPS ཡང་ན་ localhost གུ་ཁ་ཕྱེ་དགོཔ་ཨིན།",
"server.connect.camera.unavailable": "བརྡ་འཚོལ་འདི་ལུ་པར་ཆས་མེད། ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
"server.connect.camera.error":
"པར་ཆས་ཁ་ཕྱེ་མ་ཚུགས། པར་ཆས་འཛུལ་སྤྱོད་འབད་བཅུགཔ་ ཡང་ན་ ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
-6
View File
@@ -288,9 +288,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "κωδικός πρόσβασης",
"dialog.server.edit.title": "Επεξεργασία διακομιστή",
"dialog.server.menu.edit": "Επεξεργασία",
"dialog.server.menu.default": "Ορισμός ως προεπιλογή",
"dialog.server.menu.defaultRemove": "Κατάργηση προεπιλογής",
"dialog.server.status.default": "Προεπιλογή",
"dialog.project.edit.title": "Επεξεργασία έργου",
"dialog.project.edit.icon": "Εικονίδιο",
"dialog.project.edit.icon.alt": "Εικονίδιο έργου",
@@ -796,8 +793,6 @@ export const dict = {
"server.connect.address.invalid": "Εισαγάγετε μια έγκυρη διεύθυνση διακομιστή HTTP ή HTTPS.",
"server.connect.failed":
"Δεν ήταν δυνατή η σύνδεση. Ελέγξτε τη διεύθυνση του διακομιστή και τον κωδικό πρόσβασης και, στη συνέχεια, δοκιμάστε ξανά.",
"server.connect.pair.description":
"Εκτελέστε αυτήν την εντολή στον υπολογιστή σας για να λάβετε τα στοιχεία της σύνδεσής σας.",
"server.connect.scan": "Σάρωση κωδικού QR",
"server.connect.scan.description": "Στρέψτε την κάμερά σας στον κωδικό QR που εμφανίζεται από το opencode pair.",
"server.connect.scan.invalid":
@@ -806,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Άνοιγμα κάμερας…",
"server.connect.mixedContent":
"Δεν ήταν δυνατή η σύνδεση σε αυτόν τον διακομιστή HTTP από μια σελίδα HTTPS. Χρησιμοποιήστε μια διεύθυνση διακομιστή HTTPS.",
"server.connect.camera.insecure": "Η σάρωση QR απαιτεί άνοιγμα αυτής της σελίδας μέσω HTTPS ή localhost.",
"server.connect.camera.unavailable":
"Δεν υπάρχει κάμερα διαθέσιμη σε αυτό το πρόγραμμα περιήγησης. Εισαγάγετε τα στοιχεία της σύνδεσής σας με μη αυτόματο τρόπο.",
"server.connect.camera.error":
+36 -5
View File
@@ -86,6 +86,7 @@ export const dict = {
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
"palette.group.recentSessions": "Recent sessions",
"dialog.provider.search.placeholder": "Search providers",
"dialog.provider.empty": "No providers found",
@@ -340,32 +341,46 @@ export const dict = {
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.description.pairing": "Paste the pairing link from the server, or scan its QR code.",
"server.connect.link": "Pairing link",
"server.connect.link.placeholder": "http://192.168.1.2:4096/auth/connect/…",
"server.connect.link.invalid":
"Paste the full pairing link from opencode pair, or from Settings → Pairing on the server's computer.",
"server.connect.password.prompt": "No pairing link?",
"server.connect.password.use": "Use password",
"server.connect.password.hint": "To see it, run opencode service get password on the server's computer.",
"server.connect.link.prompt": "Have a pairing link?",
"server.connect.link.use": "Use pairing link",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.pair.link": "Run this command on the server's computer to get a pairing link and QR code.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.scan.failed": "This browser could not read the QR code. Enter your connection details manually.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.link.unreachable":
"Could not reach {{url}}. Check that this device can reach the server, then try again.",
"server.connect.link.legacy":
"This pairing code is from an older version of OpenCode. Update OpenCode on the server, then run opencode pair again.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
"Could not connect to this HTTP server from an HTTPS page. Use an HTTPS server address instead.",
"server.connect.camera.insecure": "QR scanning requires opening this page over HTTPS or on localhost.",
"server.connect.camera.native": "This page cannot use the camera over HTTP. Paste the pairing link above.",
"server.connect.camera.unavailable":
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"dialog.server.edit.title": "Edit server",
"dialog.server.signedOut":
"Your sign-in for this server is no longer valid. Paste a new pairing link, or enter the server password.",
"dialog.server.menu.edit": "Edit",
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.remove": "Remove",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.status.default": "Default",
"server.row.incompatible":
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
@@ -490,6 +505,7 @@ export const dict = {
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.more": "More...",
"session.view.select": "Session view",
"session.tab.add": "Add tab",
"session.tab.unknown": "Unknown Session",
@@ -661,6 +677,21 @@ export const dict = {
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.notices.title": "Third-party notices",
"settings.about.notices.description": "OpenCode includes the following open source software.",
"settings.about.notices.license": "License text",
"settings.about.notices.fonts.title": "Fonts in the Office previews",
"settings.about.notices.fonts.description":
"Each family is distributed under the SIL Open Font License 1.1, with the copyright lines below.",
"settings.about.notices.eigenpal": "Portions of BetterOffice derive from it.",
"settings.about.notices.loadFailed": "Could not load the third-party notices",
"settings.about.notices.crates.title": "Rust crates in the Office engines",
"settings.about.notices.crates.description":
"BetterOffice compiles these crates into its Word, Excel and PowerPoint engines. Where a crate offers a choice of licenses, the highlighted license applies.",
"settings.about.notices.crates.licenses": "License texts",
"settings.about.notices.crates.count.one": "{{count}} crate",
"settings.about.notices.crates.count.other": "{{count}} crates",
"settings.about.notices.crates.appliesTo": "Applies to {{names}}",
"settings.about.tagline": "AI can’t build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Ingrese una dirección de servidor HTTP o HTTPS válida.",
"server.connect.failed":
"No se pudo conectar. Verifique la dirección y la contraseña del servidor y vuelva a intentarlo.",
"server.connect.pair.description": "Ejecute este comando en su computadora para obtener los detalles de su conexión.",
"server.connect.scan": "Escanea el código QR",
"server.connect.scan.description": "Apunte su cámara al código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -32,7 +31,6 @@ export const dict = {
"server.connect.camera.starting": "Abriendo cámara...",
"server.connect.mixedContent":
"No se pudo conectar a este servidor HTTP desde una página HTTPS. Utilice una dirección de servidor HTTPS en su lugar.",
"server.connect.camera.insecure": "El escaneo de QR requiere abrir esta página en HTTPS o en localhost.",
"server.connect.camera.unavailable":
"No hay ninguna cámara disponible para este navegador. Ingrese los detalles de su conexión manualmente.",
"server.connect.camera.error":
@@ -537,9 +535,6 @@ export const dict = {
"dialog.server.edit.title": "Editar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Establecer como predeterminado",
"dialog.server.menu.defaultRemove": "Quitar como predeterminado",
"dialog.server.status.default": "Predeterminado",
"dialog.project.edit.title": "Editar proyecto",
"dialog.project.edit.icon": "Icono",
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "parool",
"dialog.server.edit.title": "Muuda serverit",
"dialog.server.menu.edit": "Muuda",
"dialog.server.menu.default": "Määra vaikeväärtuseks",
"dialog.server.menu.defaultRemove": "Eemalda vaikeseade",
"dialog.server.status.default": "Vaikimisi",
"dialog.project.edit.title": "Redigeeri projekti",
"dialog.project.edit.icon": "Ikoon",
"dialog.project.edit.icon.alt": "Projekti ikoon",
@@ -775,7 +772,6 @@ export const dict = {
"server.connect.button": "Ühendage",
"server.connect.address.invalid": "Sisestage kehtiv serveri aadress HTTP või HTTPS.",
"server.connect.failed": "Ühendust ei õnnestunud luua. Kontrollige serveri aadressi ja parooli ning proovige uuesti.",
"server.connect.pair.description": "Ühenduse üksikasjade hankimiseks käivitage see käsk oma arvutis.",
"server.connect.scan": "Skaneeri QR-kood",
"server.connect.scan.description": "Suunake oma kaamera koodile QR, mida näitab opencode pair.",
"server.connect.scan.invalid": "See ei ole OpenCode sidumiskood. Skannige koodi, mida näitab opencode pair.",
@@ -783,7 +779,6 @@ export const dict = {
"server.connect.camera.starting": "Kaamera avamine…",
"server.connect.mixedContent":
"Selle HTTP serveriga ei saanud HTTPS lehelt ühendust luua. Kasutage selle asemel HTTPS serveri aadressi.",
"server.connect.camera.insecure": "QR skannimine nõuab selle lehe avamist HTTPS või localhost kaudu.",
"server.connect.camera.unavailable":
"Selle brauseri jaoks pole kaamerat saadaval. Sisestage ühenduse üksikasjad käsitsi.",
"server.connect.camera.error":
-5
View File
@@ -295,9 +295,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "رمز عبور",
"dialog.server.edit.title": "ویرایش سرور",
"dialog.server.menu.edit": "ویرایش کنید",
"dialog.server.menu.default": "به عنوان پیش فرض تنظیم کنید",
"dialog.server.menu.defaultRemove": "حذف پیش فرض",
"dialog.server.status.default": "پیش فرض",
"dialog.project.edit.title": "ویرایش پروژه",
"dialog.project.edit.icon": "نماد",
"dialog.project.edit.icon.alt": "نماد پروژه",
@@ -795,7 +792,6 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "یک نشانی معتبر سرور HTTP یا HTTPS وارد کنید.",
"server.connect.failed": "اتصال ممکن نشد. نشانی سرور و گذرواژه را بررسی کنید و دوباره تلاش کنید.",
"server.connect.pair.description": "برای دریافت جزئیات اتصال، این فرمان را روی رایانهٔ خود اجرا کنید.",
"server.connect.scan": "پویش کد QR",
"server.connect.scan.description": "دوربین را به‌سمت کد QR نمایش‌داده‌شده توسط opencode pair بگیرید.",
"server.connect.scan.invalid": "این کد جفت‌سازی OpenCode نیست. کدی را پویش کنید که opencode pair نشان می‌دهد.",
@@ -803,7 +799,6 @@ export const dict = {
"server.connect.camera.starting": "در حال باز کردن دوربین…",
"server.connect.mixedContent":
"اتصال به این سرور HTTP از صفحهٔ HTTPS ممکن نشد. به‌جای آن از نشانی سرور HTTPS استفاده کنید.",
"server.connect.camera.insecure": "پویش QR نیازمند باز کردن این صفحه از طریق HTTPS یا روی localhost است.",
"server.connect.camera.unavailable": "هیچ دوربینی برای این مرورگر در دسترس نیست. جزئیات اتصال را دستی وارد کنید.",
"server.connect.camera.error":
"باز کردن دوربین ممکن نشد. دسترسی دوربین را مجاز کنید یا جزئیات اتصال را دستی وارد کنید.",
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Anna kelvollinen HTTP- tai HTTPS-palvelinosoite.",
"server.connect.failed":
"Yhteyttä ei voitu muodostaa. Tarkista palvelimen osoite ja salasana ja yritä sitten uudelleen.",
"server.connect.pair.description": "Suorita tämä komento tietokoneellasi saadaksesi yhteystiedot.",
"server.connect.scan": "Skannaa QR-koodi",
"server.connect.scan.description": "Suuntaa kamerasi QR-koodiin, joka näkyy opencode pair:ssä.",
"server.connect.scan.invalid": "Tämä ei ole OpenCode-pariliitoskoodi. Skannaa opencode pair:n näyttämä koodi.",
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Avataan kameraa…",
"server.connect.mixedContent":
"Yhteyttä tähän HTTP-palvelimeen ei voitu muodostaa HTTPS-sivulta. Käytä sen sijaan HTTPS-palvelinosoitetta.",
"server.connect.camera.insecure": "QR-skannaus vaatii tämän sivun avaamisen HTTPS:n kautta tai localhostissa.",
"server.connect.camera.unavailable": "Kamera ei ole saatavilla tälle selaimelle. Syötä yhteystietosi manuaalisesti.",
"server.connect.camera.error": "Kameraa ei voitu avata. Salli kameran käyttö tai anna yhteystietosi manuaalisesti.",
"dialog.server.menu.remove": "Poista",
@@ -426,9 +424,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "salasana",
"dialog.server.edit.title": "Muokkaa palvelinta",
"dialog.server.menu.edit": "Muokkaa",
"dialog.server.menu.default": "Aseta oletukseksi",
"dialog.server.menu.defaultRemove": "Poista oletuspalvelin",
"dialog.server.status.default": "Oletus",
"dialog.project.edit.title": "Muokkaa projektia",
"dialog.project.edit.icon": "Kuvake",
"dialog.project.edit.icon.alt": "Projektikuvake",
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "loyniorð",
"dialog.server.edit.title": "Rætta ambætara",
"dialog.server.menu.edit": "Rætta",
"dialog.server.menu.default": "Set sum forsett",
"dialog.server.menu.defaultRemove": "Strika forsett",
"dialog.server.status.default": "Forsett",
"dialog.project.edit.title": "Rætta verkætlan",
"dialog.project.edit.icon": "Ikon",
"dialog.project.edit.icon.alt": "Verkætlanarmerki",
@@ -779,7 +776,6 @@ export const dict = {
"server.connect.button": "Samband",
"server.connect.address.invalid": "Skriva eina gylduga HTTP ella HTTPS ambætaraadressu.",
"server.connect.failed": "Fekk ikki samband. Kanna ambætaraadressuna og loyniorðið, og royn síðani aftur.",
"server.connect.pair.description": "Koyr hesa skipanina á tínari teldu fyri at fáa tínar sambandsupplýsingar.",
"server.connect.scan": "Skanna QR-kotu",
"server.connect.scan.description": "Peika myndatólið á QR-kotuna, sum opencode pair vísir.",
"server.connect.scan.invalid": "Hetta er ikki ein OpenCode paringarkoda. Skanna kotuna, sum opencode pair vísir.",
@@ -787,7 +783,6 @@ export const dict = {
"server.connect.camera.starting": "Opna myndatól...",
"server.connect.mixedContent":
"Kundi ikki seta samband við hendan HTTP ambætaran frá eini HTTPS síðu. Brúka eina HTTPS ambætaraadressu ístaðin.",
"server.connect.camera.insecure": "QR-skanning krevur, at tú letur hesa síðuna upp yvir HTTPS ella á localhost.",
"server.connect.camera.unavailable":
"Einki myndatól er tøkt til henda kaga. Skriva tínar sambandsupplýsingar manuelt.",
"server.connect.camera.error":
-6
View File
@@ -23,8 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Entrez une adresse de serveur HTTP ou HTTPS valide.",
"server.connect.failed":
"Impossible de se connecter. Vérifiez l'adresse et le mot de passe du serveur, puis réessayez.",
"server.connect.pair.description":
"Exécutez cette commande sur votre ordinateur pour obtenir les détails de votre connexion.",
"server.connect.scan": "Scanner le code QR",
"server.connect.scan.description": "Pointez votre appareil photo vers le code QR affiché par opencode pair.",
"server.connect.scan.invalid":
@@ -33,7 +31,6 @@ export const dict = {
"server.connect.camera.starting": "Ouverture de la caméra…",
"server.connect.mixedContent":
"Impossible de se connecter à ce serveur HTTP à partir d'une page HTTPS. Utilisez plutôt une adresse de serveur HTTPS.",
"server.connect.camera.insecure": "L'analyse QR nécessite l'ouverture de cette page sur HTTPS ou sur localhost.",
"server.connect.camera.unavailable":
"Aucune caméra n'est disponible pour ce navigateur. Saisissez manuellement vos informations de connexion.",
"server.connect.camera.error":
@@ -519,9 +516,6 @@ export const dict = {
"dialog.server.add.password": "Mot de passe (optionnel)",
"dialog.server.edit.title": "Modifier le serveur",
"dialog.server.menu.edit": "Modifier",
"dialog.server.menu.default": "Définir par défaut",
"dialog.server.menu.defaultRemove": "Ne plus utiliser par défaut",
"dialog.server.status.default": "Défaut",
"dialog.project.edit.title": "Modifier le projet",
"dialog.project.edit.icon": "Icône",
"dialog.project.edit.icon.alt": "Icône du projet",
-5
View File
@@ -294,9 +294,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "סיסמה",
"dialog.server.edit.title": "ערוך שרת",
"dialog.server.menu.edit": "ערוך",
"dialog.server.menu.default": "הגדר כברירת מחדל",
"dialog.server.menu.defaultRemove": "הסר את ברירת המחדל",
"dialog.server.status.default": "ברירת מחדל",
"dialog.project.edit.title": "ערוך פרויקט",
"dialog.project.edit.icon": "סמל",
"dialog.project.edit.icon.alt": "סמל פרויקט",
@@ -793,14 +790,12 @@ export const dict = {
"server.connect.button": "התחברות",
"server.connect.address.invalid": "יש להזין כתובת שרת HTTP או HTTPS תקינה.",
"server.connect.failed": "לא ניתן להתחבר. יש לבדוק את כתובת השרת והסיסמה ולנסות שוב.",
"server.connect.pair.description": "יש להריץ פקודה זו במחשב כדי לקבל את פרטי החיבור.",
"server.connect.scan": "סריקת קוד QR",
"server.connect.scan.description": "יש לכוון את המצלמה לקוד QR שמוצג על ידי opencode pair.",
"server.connect.scan.invalid": "זה אינו קוד צימוד של OpenCode. יש לסרוק את הקוד שמוצג על ידי opencode pair.",
"server.connect.camera": "מצלמת צימוד",
"server.connect.camera.starting": "פותח את המצלמה…",
"server.connect.mixedContent": "לא ניתן להתחבר לשרת HTTP זה מדף HTTPS. יש להשתמש בכתובת שרת HTTPS במקום זאת.",
"server.connect.camera.insecure": "סריקת QR דורשת פתיחת דף זה דרך HTTPS או ב-localhost.",
"server.connect.camera.unavailable": "אין מצלמה זמינה לדפדפן זה. יש להזין את פרטי החיבור ידנית.",
"server.connect.camera.error": "לא ניתן לפתוח את המצלמה. יש לאפשר גישה למצלמה או להזין את פרטי החיבור ידנית.",
"dialog.server.menu.remove": "הסרה",
-5
View File
@@ -292,9 +292,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
"dialog.server.edit.title": "सर्वर संपादित करें",
"dialog.server.menu.edit": "संपादित करें",
"dialog.server.menu.default": "डिफाल्ट के रूप में सेट",
"dialog.server.menu.defaultRemove": "डिफ़ॉल्ट हटाएँ",
"dialog.server.status.default": "डिफ़ॉल्ट",
"dialog.project.edit.title": "प्रोजेक्ट संपादित करें",
"dialog.project.edit.icon": "आइकन",
"dialog.project.edit.icon.alt": "प्रोजेक्ट आइकन",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "जोड़ना",
"server.connect.address.invalid": "एक वैध HTTP या HTTPS सर्वर पता दर्ज करें।",
"server.connect.failed": "कनेक्ट नहीं हो सका। सर्वर पता और पासवर्ड जांचें, फिर पुनः प्रयास करें।",
"server.connect.pair.description": "अपना कनेक्शन विवरण प्राप्त करने के लिए इस कमांड को अपने कंप्यूटर पर चलाएँ।",
"server.connect.scan": "QR कोड स्कैन करें",
"server.connect.scan.description": "अपने कैमरे को ओपनकोड जोड़ी द्वारा दिखाए गए QR कोड पर इंगित करें।",
"server.connect.scan.invalid": "यह कोई OpenCode युग्मन कोड नहीं है. ओपनकोड जोड़ी द्वारा दिखाए गए कोड को स्कैन करें।",
@@ -805,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "कैमरा खुल रहा है...",
"server.connect.mixedContent":
"किसी HTTPS पेज से इस HTTP सर्वर से कनेक्ट नहीं किया जा सका। इसके बजाय HTTPS सर्वर पते का उपयोग करें।",
"server.connect.camera.insecure": "QR स्कैनिंग के लिए इस पृष्ठ को HTTPS या लोकलहोस्ट पर खोलने की आवश्यकता है।",
"server.connect.camera.unavailable":
"इस ब्राउज़र पर कोई कैमरा उपलब्ध नहीं है. अपना कनेक्शन विवरण मैन्युअल रूप से दर्ज करें।",
"server.connect.camera.error":
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "lozinka",
"dialog.server.edit.title": "Uredi poslužitelj",
"dialog.server.menu.edit": "Uredi",
"dialog.server.menu.default": "Postavi kao zadano",
"dialog.server.menu.defaultRemove": "Ukloni zadano",
"dialog.server.status.default": "Zadano",
"dialog.project.edit.title": "Uredi projekt",
"dialog.project.edit.icon": "Ikona",
"dialog.project.edit.icon.alt": "Ikona projekta",
@@ -805,7 +802,6 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu poslužitelja.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu poslužitelja i lozinku, pa pokušajte ponovno.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računalu da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -813,8 +809,6 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP poslužiteljem s HTTPS stranice. Umjesto toga koristite HTTPS adresu poslužitelja.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localračunalu domaćinu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "jelszó",
"dialog.server.edit.title": "Szerver szerkesztése",
"dialog.server.menu.edit": "Szerkesztés",
"dialog.server.menu.default": "Beállítás alapértelmezettként",
"dialog.server.menu.defaultRemove": "Alapértelmezés eltávolítása",
"dialog.server.status.default": "Alapértelmezett",
"dialog.project.edit.title": "Projekt szerkesztése",
"dialog.project.edit.icon": "Ikon",
"dialog.project.edit.icon.alt": "Projekt ikonra",
@@ -794,8 +791,6 @@ export const dict = {
"server.connect.button": "Csatlakozás",
"server.connect.address.invalid": "Adjon meg egy érvényes HTTP vagy HTTPS szervercímet.",
"server.connect.failed": "Nem sikerült csatlakozni. Ellenőrizze a szerver címét és jelszavát, majd próbálja újra.",
"server.connect.pair.description":
"Futtassa ezt a parancsot a számítógépén a kapcsolat részleteinek megtekintéséhez.",
"server.connect.scan": "QR-kód beolvasása",
"server.connect.scan.description": "Irányítsa kameráját a opencode pair által mutatott QR kódra.",
"server.connect.scan.invalid": "Ez nem egy OpenCode párosítási kód. Olvassa be a opencode pair által mutatott kódot.",
@@ -803,7 +798,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera nyitása…",
"server.connect.mixedContent":
"Nem sikerült csatlakozni ehhez a HTTP szerverhez egy HTTPS oldalról. Használjon helyette HTTPS szervercímet.",
"server.connect.camera.insecure": "A QR beolvasásához ezt az oldalt a HTTPS vagy a localhost oldalon kell megnyitni.",
"server.connect.camera.unavailable":
"Ehhez a böngészőhöz nem érhető el kamera. Adja meg manuálisan a kapcsolat adatait.",
"server.connect.camera.error":
-5
View File
@@ -287,9 +287,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "գաղտնաբառ",
"dialog.server.edit.title": "Խմբագրել սերվերը",
"dialog.server.menu.edit": "Խմբագրել",
"dialog.server.menu.default": "Սահմանել որպես լռելյայն",
"dialog.server.menu.defaultRemove": "Հեռացնել լռելյայն",
"dialog.server.status.default": "Լռելյայն",
"dialog.project.edit.title": "Խմբագրել նախագիծը",
"dialog.project.edit.icon": "Պատկերակ",
"dialog.project.edit.icon.alt": "Նախագծի պատկերակ",
@@ -795,14 +792,12 @@ export const dict = {
"server.connect.button": "Կապվել",
"server.connect.address.invalid": "Մուտքագրեք վավեր HTTP կամ HTTPS սերվերի հասցե։",
"server.connect.failed": "Չկապվեց. Ստուգեք սերվերի հասցեն և գաղտնաբառը, ապա փորձեք նորից։",
"server.connect.pair.description": "Գործարկեք այս հրամանը ձեր համակարգչում՝ ստանալու ձեր կապի մանրամասները։",
"server.connect.scan": "Սկանավորեք QR կոդը",
"server.connect.scan.description": "Տեսախցիկն ուղղեք opencode pair-ի ցուցադրած QR կոդին։",
"server.connect.scan.invalid": "Սա OpenCode-ի զուգակցման կոդ չէ։ Սկանավորեք opencode pair-ի ցուցադրած կոդը։",
"server.connect.camera": "Զույգագրման վահանակը",
"server.connect.camera.starting": "Բացվում է վահանակը…",
"server.connect.mixedContent": "Այս HTTP սերվերին հնարավոր չէ միանալ HTTPS էջից։ Օգտագործեք HTTPS սերվերի հասցե։",
"server.connect.camera.insecure": "QR սկանումը պահանջում է այս էջը բացել HTTPS-ով կամ տեղական հոստում։",
"server.connect.camera.unavailable": "Այս զննարկչին վահանակ հասանելի չէ։ Մուտքագրեք ձեր կապի մանրամասները ձեռքով։",
"server.connect.camera.error":
"Չկարողացա բացել տեսախցիկը։ Թույլատրել տեսախցիկի մուտքը կամ ձեռքով ներմուծել ձեր կապի տվյալները։",
-5
View File
@@ -323,9 +323,6 @@ export const dict = {
"dialog.server.edit.title": "Sunting server",
"dialog.server.menu.edit": "Sunting",
"dialog.server.menu.default": "Tetapkan sebagai bawaan",
"dialog.server.menu.defaultRemove": "Hapus bawaan",
"dialog.server.status.default": "Bawaan",
"dialog.project.edit.title": "Sunting proyek",
"dialog.project.edit.icon": "Ikon",
@@ -862,7 +859,6 @@ export const dict = {
"server.connect.button": "Sambungkan",
"server.connect.address.invalid": "Masukkan alamat server HTTP atau HTTPS yang valid.",
"server.connect.failed": "Tidak dapat terhubung. Periksa alamat server dan kata sandi, lalu coba lagi.",
"server.connect.pair.description": "Jalankan perintah ini di komputer Anda untuk mendapatkan detail koneksi Anda.",
"server.connect.scan": "Pindai kode QR",
"server.connect.scan.description": "Arahkan kamera Anda ke kode QR yang ditampilkan oleh opencode pair.",
"server.connect.scan.invalid": "Ini bukan kode pemasangan OpenCode. Pindai kode yang ditampilkan oleh opencode pair.",
@@ -870,7 +866,6 @@ export const dict = {
"server.connect.camera.starting": "Membuka kamera…",
"server.connect.mixedContent":
"Tidak dapat terhubung ke server HTTP ini dari halaman HTTPS. Gunakan alamat server HTTPS sebagai gantinya.",
"server.connect.camera.insecure": "Pemindaian QR memerlukan halaman ini dibuka melalui HTTPS atau di localhost.",
"server.connect.camera.unavailable":
"Tidak ada kamera yang tersedia untuk peramban ini. Masukkan detail koneksi Anda secara manual.",
"server.connect.camera.error":
-5
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "lykilorð",
"dialog.server.edit.title": "Breyta miðlara",
"dialog.server.menu.edit": "Breyta",
"dialog.server.menu.default": "Stillt sem sjálfgefið",
"dialog.server.menu.defaultRemove": "Fjarlægja sjálfgefið",
"dialog.server.status.default": "Sjálfgefið",
"dialog.project.edit.title": "Breyta verkefni",
"dialog.project.edit.icon": "Táknmynd",
"dialog.project.edit.icon.alt": "Verkefnistákn",
@@ -782,7 +779,6 @@ export const dict = {
"server.connect.button": "Tengdu",
"server.connect.address.invalid": "Sláðu inn gilt HTTP eða HTTPS netfang netþjóns.",
"server.connect.failed": "Ekki tókst að tengjast. Athugaðu netfang netþjóns og lykilorð, reyndu svo aftur.",
"server.connect.pair.description": "Keyrðu þessa skipun á tölvunni þinni til að fá upplýsingar um tenginguna þína.",
"server.connect.scan": "Skanna QR kóða",
"server.connect.scan.description": "Beindu myndavélinni þinni að QR kóðanum sem opencode pair sýnir.",
"server.connect.scan.invalid": "Þetta er ekki OpenCode pörunarkóði. Skannaðu kóðann sem opencode pair sýnir.",
@@ -790,7 +786,6 @@ export const dict = {
"server.connect.camera.starting": "Opnar myndavél...",
"server.connect.mixedContent":
"Gat ekki tengst þessum HTTP netþjóni frá HTTPS síðu. Notaðu HTTPS netþjónsfang í staðinn.",
"server.connect.camera.insecure": "QR skönnun krefst þess að opna þessa síðu yfir HTTPS eða á localhost.",
"server.connect.camera.unavailable":
"Engin myndavél er í boði fyrir þennan vafra. Sláðu inn tengiupplýsingar þínar handvirkt.",
"server.connect.camera.error":
-6
View File
@@ -22,8 +22,6 @@ export const dict = {
"server.connect.button": "Connettiti",
"server.connect.address.invalid": "Inserisci un indirizzo server HTTP o HTTPS valido.",
"server.connect.failed": "Impossibile connettersi. Controlla l'indirizzo del server e la password, quindi riprova.",
"server.connect.pair.description":
"Esegui questo comando sul tuo computer per ottenere i dettagli della connessione.",
"server.connect.scan": "Scansiona il codice QR",
"server.connect.scan.description": "Punta la fotocamera sul codice QR mostrato da opencode pair.",
"server.connect.scan.invalid":
@@ -32,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Apertura della fotocamera…",
"server.connect.mixedContent":
"Impossibile connettersi a questo server HTTP da una pagina HTTPS. Utilizza invece un indirizzo server HTTPS.",
"server.connect.camera.insecure": "La scansione QR richiede l'apertura di questa pagina su HTTPS o su localhost.",
"server.connect.camera.unavailable":
"Nessuna fotocamera è disponibile per questo browser. Inserisci manualmente i dettagli della tua connessione.",
"server.connect.camera.error":
@@ -449,9 +446,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Modifica server",
"dialog.server.menu.edit": "Modifica",
"dialog.server.menu.default": "Imposta come predefinito",
"dialog.server.menu.defaultRemove": "Rimuovi predefinito",
"dialog.server.status.default": "Predefinito",
"dialog.project.edit.title": "Modifica progetto",
"dialog.project.edit.icon": "Icona",
"dialog.project.edit.icon.alt": "Icona del progetto",
-5
View File
@@ -296,9 +296,6 @@ export const dict = {
"dialog.server.add.password": "パスワード (オプション)",
"dialog.server.edit.title": "サーバーを編集",
"dialog.server.menu.edit": "編集",
"dialog.server.menu.default": "デフォルトに設定",
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
"dialog.server.status.default": "デフォルト",
"dialog.project.edit.title": "プロジェクトを編集",
"dialog.project.edit.icon": "アイコン",
@@ -808,7 +805,6 @@ export const dict = {
"server.connect.button": "接続",
"server.connect.address.invalid": "有効なHTTPまたはHTTPSサーバーアドレスを入力してください。",
"server.connect.failed": "接続できませんでした。サーバーアドレスとパスワードを確認して、もう一度お試しください。",
"server.connect.pair.description": "このコマンドをコンピューターで実行して接続情報を取得してください。",
"server.connect.scan": "QRコードをスキャン",
"server.connect.scan.description": "opencode pair に表示されたQRコードにカメラを向けてください。",
"server.connect.scan.invalid":
@@ -817,7 +813,6 @@ export const dict = {
"server.connect.camera.starting": "カメラを開いています…",
"server.connect.mixedContent":
"HTTPSページからこのHTTPサーバーに接続できません。代わりにHTTPSサーバーのアドレスを使用してください。",
"server.connect.camera.insecure": "QRスキャンには、このページをHTTPSで開くか、ローカルホストで開く必要があります。",
"server.connect.camera.unavailable": "このブラウザで使用可能なカメラがありません。接続情報を手動で入力してください。",
"server.connect.camera.error":
"カメラを開くことができません。カメラへのアクセスを許可するか、接続情報を手動で入力してください。",
Loaded 100 of 723 files, more files were not shown because too many files have changed in this diff. Show more