Compare commits

...
Author SHA1 Message Date
Aiden Cline c8397a9891 fix(opencode): resolve Copilot models to their bundled SDK and Responses endpoint
Copilot serves Grok, Gemini, and MAI Code only on /responses, but the endpoint fallback only routed gpt-5+ there, so they hit /chat/completions whenever the live model metadata was missing.

The catalog only carries the generic @ai-sdk/openai-compatible package for this provider, and config-defined models that do not reference a catalog model are merged after the Copilot plugin's SDK remap, so they fall through to the generic SDK instead of the bundled one required for CAPI URL, headers, and error handling. Explicit config still wins.

Advertised supported_endpoints metadata still takes precedence.
2026-09-23 23:05:09 -05:00
opencode-agent[bot] 5fcfb06f94 chore: update nix node_modules hashes 2026-09-24 02:18:19 +00:00
Vladimir Glafirov 79e8eee5cf chore: bump gitlab-ai-provider to 6.18.0 (#50890) 2026-09-23 21:03:37 -05:00
opencode-agent[bot]andrekram1-node 82d4c89031 fix(opencode): redact credentials in debug config (#50956)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-23 17:35:46 -05:00
Jack 1d6c3c0e29 feat(go): add GPT 6 Luna to Go pages (#50947) 2026-09-24 02:25:48 +08:00
Mark McDonaldandAiden Cline 610df0b566 fix(gemini): switch thinking default logic (#50841)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-23 11:41:37 -05:00
opencode-agent[bot] 7cb044ee89 chore: generate 2026-09-23 13:59:42 +00:00
Jack 1b4a6dbc65 feat(go): add Space Bunny promotion (#50572) 2026-09-23 21:58:18 +08:00
opencode-agent[bot] 18ef3cc7c5 chore: update nix node_modules hashes 2026-09-23 04:38:22 +00:00
Vladimir Glafirov 45719acebb maint(gitlab): bump gitlab-ai-provider to 6.16.0 (#50742) 2026-09-22 23:21:33 -05:00
opencode-agent[bot] 2406400f0a chore: generate 2026-09-22 19:25:10 +00:00
vprdev fe51b0b19a fix(console): restrict legacy access to Black (#50716) 2026-09-22 12:23:33 -07:00
Daniel Chen 6ea95e5e6f docs(web): add Claude Opus 5.5, GPT 6 Sol, and GPT 6 Luna to Zen (#50708) 2026-09-22 11:54:04 -07:00
opencode-agent[bot] fc827ea8d5 chore: generate 2026-09-22 18:38:53 +00:00
opencode-agent[bot]andrekram1-node 3a35b45db8 feat(openai): allow GPT-6 Sol and Luna with Codex (#50704)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-22 13:36:43 -05:00
opencode fe3f3a41f7 sync release versions for v1.18.32 2026-09-21 22:51:18 +00:00
f5ce4f881e fix(core): break filesystem search import cycle (#50439)
Co-authored-by: frank <48937772+joe-ieta@users.noreply.github.com>
Co-authored-by: kernel-oops <300539221+kernel-oops@users.noreply.github.com>
2026-09-21 16:41:28 -05:00
opencode-agent[bot] f54ce313b9 chore: generate 2026-09-21 19:56:28 +00:00
Jack 32808ad5f1 docs: add MiMo V2.6 models (#50395) 2026-09-22 03:55:06 +08:00
Aiden Cline ba341c6cac fix(core): resolve npm package entrypoint to a file under Node (#50413) 2026-09-21 14:36:52 -05:00
Daniel Chen e059ac5918 feat: add Grok 4.7 to Zen and Go (#50288) 2026-09-21 08:32:28 -07:00
Frank 70a24697ea fix(console): authenticate inference proxy 2026-09-21 04:40:11 -04:00
Frank cf494c2029 fix(console): forward client IP to inference 2026-09-21 04:02:27 -04:00
Aiden Cline c10134729d fix(opencode): hoist Bedrock tool images except Claude, Nova, and Llama 4 (#50272) 2026-09-20 22:31:41 -05:00
opencode-agent[bot] 0e3dfd1769 chore: update nix node_modules hashes 2026-09-21 02:15:03 +00:00
Aiden Cline 8bf288ecb1 fix(opencode): bump togetherai so streams report usage (#50264) 2026-09-20 21:01:45 -05:00
Jack 17b7678875 docs(web): extend DeepSeek promotion (#50255) 2026-09-21 08:40:48 +08:00
Adam d870e22c70 fix(stats): retry transient query failures (#50205) 2026-09-20 17:43:04 +00:00
Adam 45ad8dc38a fix(stats): paginate sync aggregates (#50198) 2026-09-20 17:00:07 +00:00
opencode-agent[bot] 8b2de7317b chore: generate 2026-09-20 16:23:45 +00:00
Aiden Cline ac88182b3e ci: sync v2 catalog once a day (#50194) 2026-09-20 11:22:28 -05:00
Frank ebb7b76eca docs(web): align Jev section heading 2026-09-19 20:49:02 -04:00
Frank 11be5bc29c docs(web): document Jev usage 2026-09-19 20:41:21 -04:00
Aiden Cline 83abc64a5c chore(ci): refresh v2 models.dev snapshot on a schedule (#50030) 2026-09-19 16:46:33 -05:00
opencode-agent[bot]andthdxr fee476bb90 refactor(console): redirect desktop downloads (#49984)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-19 14:31:47 +00:00
opencode-agent[bot]andBrendonovich ae93d4afb3 fix(console): use v2 desktop downloads (#49931)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 09:01:25 +00:00
opencode-agent[bot] 9a53565790 chore: generate 2026-09-19 08:05:40 +00:00
Frank 61eae8811a docs(web): add Jev models to Zen 2026-09-19 04:04:16 -04:00
Frank 34b4c3cd9f fix(console): update systemone endpoints 2026-09-19 03:37:25 -04:00
Dax Raad 3c7f191972 fix(web): add v2 banner border and restore header hairline 2026-09-19 03:35:42 -04:00
Dax Raad 7c22dbbaf9 fix(web): use neutral colors for v2 banner 2026-09-19 03:29:50 -04:00
Dax Raad 6da6d47aab fix(web): render v2 banner full width below header 2026-09-19 03:29:17 -04:00
Dax Raad f1aacaba22 feat(web): add v2 announcement banner to legacy docs 2026-09-19 03:14:51 -04:00
opencode-agent[bot] 4e1c49630a chore: generate 2026-09-19 04:17:02 +00:00
Daniel Chen a6cad7743f docs: add DeepSeek V4.1 Flash to Zen (#49897) 2026-09-18 21:15:46 -07:00
Jack ae2da69004 docs: add Qwen3.8 Flash to Zen (#49888) 2026-09-19 11:30:51 +08:00
Dax 285cff53da feat(console): switch install links to v2 (#49874) 2026-09-19 01:27:27 +00:00
opencode-agent[bot] 5f9d9187c0 chore: generate 2026-09-18 15:59:01 +00:00
Frank 1573a7b608 support jev 2026-09-18 11:57:10 -04:00
Jack 3dd1b30539 docs: restore GPT-5.6 Sol pricing (#49724) 2026-09-18 18:06:35 +08:00
opencode-agent[bot] e80db11160 chore: generate 2026-09-18 09:14:23 +00:00
Jack a7f2373a05 chore(go): retire Union Alpha (#49718) 2026-09-18 17:13:11 +08:00
opencode-agent[bot]andrekram1-node b02acc1e30 chore(stats): update GitHub star fallback (#49654)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 20:50:22 -05:00
opencode-agent[bot]andvimtor 5a8335857b fix(console): show migrated workspaces in selector (#49520)
Co-authored-by: vimtor <vimtor@users.noreply.github.com>
2026-09-17 11:25:14 +02:00
Adam 88c6c7abc7 fix(stats): preserve container dependency lock (#49385) 2026-09-16 13:15:53 -05:00
opencode-agent[bot]andadamdotdevin a6183af6f3 fix(stats): hide Union Alpha provider (#49356)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 12:47:12 -05:00
opencode-agent[bot]andadamdotdevin 2e018f70f2 fix(stats): guard oversized model names (#49363)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 16:47:21 +00:00
Jack 1364769e51 fix(go): label Union Alpha as free (#49353) 2026-09-16 23:30:58 +08:00
opencode-agent[bot] 350c726aa8 chore: generate 2026-09-16 13:44:33 +00:00
Jack ee91f190c5 feat(go): add Union Alpha (#49178) 2026-09-16 21:42:09 +08:00
Victor Navarro 501ff62cbf fix(console): exclude migrated workspaces from selection (#49336) 2026-09-16 15:07:22 +02:00
Frank e03db9bc69 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-14 17:41:36 -04:00
Frank 403627b031 deepseek v4.1 flash 2026-09-14 17:41:34 -04:00
vprdev 5cf4e13588 fix(console): route new Console keys to inference (#49036) 2026-09-14 14:32:56 -07:00
opencode-agent[bot]andrekram1-node 4ae176520d chore: add Alan to team members (#49054)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-14 14:16:46 -07:00
opencode a74c472ffb sync release versions for v1.18.31 2026-09-14 17:47:27 +00:00
opencode-agent[bot]andthdxr a97622c801 fix(tui): surface remote auth startup errors (#49016)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-14 13:24:08 -04:00
opencode-agent[bot]andrekram1-node 7f209437b5 test(app): retire legacy layout e2e coverage (#49015)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-14 12:08:40 -05:00
Matthew Feroz 228e9095ba fix(ui): add Merge Gateway logo (#42015) 2026-09-14 02:16:48 -05:00
Frank df23b7f948 feat(console): forward Go billing source 2026-09-13 17:38:53 -04:00
opencode-agent[bot] 631f67a9f3 chore: update nix node_modules hashes 2026-09-13 17:14:41 +00:00
Rohan Tanejaandrekram1-node 199a4cdbea chore(deps): bump @ai-sdk/gateway to 3.0.191 (#48710)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-13 11:56:55 -05:00
opencode-agent[bot] a453386e9d chore: generate 2026-09-13 15:24:43 +00:00
Jack ca34cecc03 docs(go): show DeepSeek V4.1 Flash promo limits (#48824) 2026-09-13 23:23:29 +08:00
Jacob WolfandShoubhit Dash 95daf90670 fix(acp): restore session options and reasoning boundaries (#48225)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-11 18:46:41 +05:30
opencode-agent[bot] 0b934e9516 chore: generate 2026-09-11 13:07:53 +00:00
黑墨水鱼 5de2f241c6 feat(console): add batch workspace block endpoints (#48491) 2026-09-11 21:06:26 +08:00
Adam 193de13a88 fix(stats): normalize deepseek v4.1 flash 2026-09-10 15:33:35 -05:00
Adam 43e89ea167 fix(stats): prevent full retention sync timeouts 2026-09-10 14:50:04 -05:00
Jack b7ca4f91d9 docs(go): update DeepSeek V4.1 Flash model ID (#48363) 2026-09-11 01:53:15 +08:00
Victor Navarro b3f1a96c6d fix(console): unblock Go subscriptions in dev (#48309) 2026-09-10 11:25:59 +02:00
Jack 859106eb17 docs(go): remove Omen Alpha listings (#48280) 2026-09-10 12:22:43 +08:00
opencode-agent[bot] 486e8460b1 chore: generate 2026-09-10 03:06:55 +00:00
Jack 28a62b7148 feat(go): add DeepSeek V4.1 Flash (#48270) 2026-09-10 11:05:41 +08:00
Aiden ClineandAljosha Friemann a9a6fad0fa fix(opencode): request summarized adaptive thinking (#48269)
Co-authored-by: Aljosha Friemann <1730315+afriemann@users.noreply.github.com>
2026-09-09 21:51:42 -05:00
opencode-agent[bot] b6914b39db chore: generate 2026-09-09 22:45:26 +00:00
JackandFrank 72635a3720 feat(console): clarify Go model usage (#48192)
Co-authored-by: Frank <frank@anoma.ly>
2026-09-09 18:44:00 -04:00
Victor Navarro 9f8db119fc feat(console): route migrated Go inference (#48123) 2026-09-09 19:45:38 +02:00
Jack f69beceaff docs(go): update GLM-5.3-Flash allowance (#48130) 2026-09-09 23:41:56 +08:00
opencode 830d5eb535 sync release versions for v1.18.30 2026-09-09 03:34:25 +00:00
Aiden Cline 5cd8e68fdd feat(opencode): port Astra system prompt from v2 (#48057) 2026-09-08 21:27:13 -05:00
opencode-agent[bot] dff8fbc149 chore: generate 2026-09-08 16:21:49 +00:00
Emrickandyeqisong ac1758c0e6 fix: preserve Bedrock DeepSeek model ids (#34441)
Co-authored-by: yeqisong <yeqisong@authing.com>
2026-09-08 11:20:22 -05:00
opencode-agent[bot] d6855b6b47 chore: generate 2026-09-08 06:51:49 +00:00
Jack eebd85f5d1 docs(go): translate client compatibility guidance (#47901) 2026-09-08 14:50:35 +08:00
opencode-agent[bot] ecbc6ccac8 chore: update nix node_modules hashes 2026-09-07 16:21:14 +00:00
Vladimir Glafirov 0b082b065d chore: bump gitlab-ai-provider to 6.15.0 (#47792) 2026-09-07 11:03:30 -05:00
Aiden Cline 57ef382843 feat(console): publish oauth client metadata document (#47737) 2026-09-07 01:11:00 -05:00
Dax Raad f914cac3d4 docs 2026-09-07 01:49:16 -04:00
opencode-agent[bot] 53fec37d8d chore: generate 2026-09-07 04:03:53 +00:00
Dax Raad a03bba5f2c docs 2026-09-07 00:02:27 -04:00
opencode-agent[bot] 56d4fb24a1 chore: generate 2026-09-07 03:44:38 +00:00
Dax Raad 13e3744f76 docs(go): document client session compatibility 2026-09-06 23:42:25 -04:00
opencode-agent[bot] e207624c48 chore: update nix node_modules hashes 2026-09-06 22:05:22 +00:00
opencode-agent[bot]andrekram1-node ea2d59d7ca fix(provider): preserve explicit OpenAI service tiers (#47671)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 16:48:43 -05:00
opencode-agent[bot]andrekram1-node bec9ee41af fix(provider): bump Azure SDK to 3.0.93 (#47664)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 16:34:09 -05:00
opencode-agent[bot] c470c79513 chore: update nix node_modules hashes 2026-09-06 20:59:14 +00:00
23ec4f55c8 fix(provider): bump OpenAI SDK to 3.0.88 (#47659)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: GuestAUser <62957566+GuestAUser@users.noreply.github.com>
2026-09-06 15:40:58 -05:00
opencode-agent[bot] 337fd144d2 chore: generate 2026-09-06 04:32:46 +00:00
Jack 1ddaeec480 docs(console): add localized refund FAQs for Go and Zen (#47575) 2026-09-06 12:31:27 +08:00
far-ouq 7c2199d84a fix(opencode): add GitLab reasoning variants (#47306) 2026-09-05 18:29:29 -05:00
Stefan Avramandslickstef11 bbd72fb8b0 fix(console): connect enterprise form to Chatwoot (#47488)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 15:51:32 +02:00
黑墨水鱼andJack e2894562f8 fix(console): openai usage normalization and tier threshold config (#47342)
Co-authored-by: Jack <jack@anoma.ly>
2026-09-05 03:36:32 +00:00
Frank 70b4ca8c18 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-04 21:49:05 -04:00
Frank 4db1eec321 update zen docs 2026-09-04 21:48:55 -04:00
opencode-agent[bot] 79903a4cf7 chore: generate 2026-09-05 01:40:11 +00:00
Frank e8548ae8b2 update zen models 2026-09-04 21:38:36 -04:00
Frank 7774580e61 zen: add muse spark 2026-09-04 21:32:15 -04:00
opencode 5b1e31988e sync release versions for v1.18.29 2026-09-04 23:47:13 +00:00
Aiden Cline 02a167e048 fix(opencode): compare Codex GPT versions by major and minor (#47385) 2026-09-04 18:22:56 -05:00
Aiden Cline 500c46ec79 fix(opencode): allow integer GPT versions in Codex model filter (#47384) 2026-09-04 18:14:21 -05:00
黑墨水鱼 5cf9f517cf feat(console): add quota reset support action (#47313) 2026-09-05 02:47:06 +08:00
Peter267 5d5c35ee71 docs(zh): fix bold rendering by adding spaces around asterisks (#46231) 2026-09-04 12:50:14 -05:00
opencode 51f86c8537 sync release versions for v1.18.28 2026-09-04 15:38:19 +00:00
Jack 20a7743876 docs(go): renew DeepSeek ZDR through September 2026 (#47288) 2026-09-04 21:31:04 +08:00
opencode-agent[bot] 31afdd5938 chore: generate 2026-09-04 12:34:53 +00:00
Adam 4178fd74f1 fix(stats): hide stealth model providers 2026-09-04 07:33:04 -05:00
Victor Navarro 3f31139064 feat(console): route migrated BYOK through provider connections (#47266) 2026-09-04 12:42:58 +02:00
Jack 475b408119 fix(console): backport usage reset boundary fix to dev (#47267) 2026-09-04 18:41:12 +08:00
opencode-agent[bot]andfwang 70f74112e3 fix(stats): keep omen-alpha under unknown provider (#47248)
Co-authored-by: fwang <83515+fwang@users.noreply.github.com>
2026-09-04 04:22:48 -04:00
opencode-agent[bot]andrekram1-node 03cb632435 test(core): disable npm audits in the test preload (#47222)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-04 00:34:37 -05:00
opencode-agent[bot] dd417f1a6b chore: generate 2026-09-04 05:09:10 +00:00
Jack 8b9f89e7e8 docs(go): add Omen Alpha (#47220) 2026-09-04 13:07:43 +08:00
opencode-agent[bot]andrekram1-node c0f09afef5 feat(copilot): send X-Interaction-Id header with session id (#47215)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 23:24:55 -05:00
Adam 8a6cf2c9aa feat(stats): improve market share chart (#47115) 2026-09-03 16:03:32 -05:00
opencode-agent[bot] a935432b5c chore: generate 2026-09-03 19:58:32 +00:00
David Hill 7561b4a050 fix(tui): use unicode ellipses in interface text (#45126) 2026-09-03 13:56:40 -06:00
opencode-agent[bot] d8eb3b80fb chore: generate 2026-09-03 17:42:08 +00:00
Adam 08c483dc36 fix(stats): reduce database query load 2026-09-03 12:38:24 -05:00
Victor Navarro d2efd81fb3 fix(console): proxy migrated model discovery to v1 endpoint (#47065) 2026-09-03 17:04:58 +02:00
Jack 79d503150c docs(web): translate Go usage requirements (#47057) 2026-09-03 21:52:04 +08:00
opencode-agent[bot] f12e14cf16 fix(app): identify desktop in Console device auth (v1) (#47000) 2026-09-03 11:01:59 +02:00
David Hill b578b7261f fix(app): increase open-in icon size (#46540) 2026-09-03 09:47:21 +08:00
Victor M. SMITH bbe4c952d7 docs: add Eden AI to the providers list (#43386) 2026-09-02 20:35:18 -05:00
opencode-agent[bot] 3a9d4e78b6 chore: update nix node_modules hashes 2026-09-02 23:33:47 +00:00
Vladimir Glafirov 8d1f8916d3 chore: bump gitlab-ai-provider to 6.13.0 (#46914) 2026-09-02 18:18:04 -05:00
opencode 05028334b2 sync release versions for v1.18.27 2026-09-02 21:40:57 +00:00
Aiden Cline b04697366f fix(opencode): default header timeout to five minutes (#46903) 2026-09-02 16:00:54 -05:00
Aiden Cline 4eb29a64f0 fix(opencode): default chunk timeout to five minutes (#46890) 2026-09-02 15:33:15 -05:00
Darien KindlundandAiden Cline 68abdce1a0 fix(opencode): let config opt out of Anthropic thinking blockBinding (#46820)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-02 14:15:39 -05:00
Victor Navarro ef2792511d fix(console): restore migrated inference proxy requests (#46854) 2026-09-02 18:44:43 +02:00
Aiden Cline 9a71624d2d fix(provider): scope thinking binding to Claude 5.1+ (#46848) 2026-09-02 11:30:28 -05:00
opencode-agent[bot] ffbdee7b17 chore: generate 2026-09-02 16:06:24 +00:00
Jack 77aec36da1 feat(console): add Muse Spark 1.3 and Gemini 3.8 Flash (#46836) 2026-09-03 00:04:37 +08:00
Victor Navarro 50efc055de feat(console): keep migrated teams on the new Console (#46830) 2026-09-02 17:52:15 +02:00
Alex 69c172e8a7 fix(provider): handle SSE reader cancel rejections (#44944) 2026-09-01 23:28:47 -05:00
Jack 82b665075b docs(zen): add Claude Fable 5.1 (#46728) 2026-09-02 11:54:40 +08:00
opencode 8e0f1c253b sync release versions for v1.18.26 2026-09-01 21:52:12 +00:00
opencode-agent[bot] 8ff796f133 chore: update nix node_modules hashes 2026-09-01 21:18:22 +00:00
Aiden Cline 8100c68b50 fix(app): bump happy-dom to fix GC-dependent MutationObserver flake (#46675) 2026-09-01 16:00:24 -05:00
Aiden Cline 86387e90b2 test(opencode): fix session tools test typecheck and runtime (#46677) 2026-09-01 15:24:30 -05:00
opencode-agent[bot] c10d6767c9 chore: update nix node_modules hashes 2026-09-01 20:18:42 +00:00
Roscoe A. Bartlett 765ae641d7 fix(core): Fix for incorrect time.start reset in tool call logging (#32574) (#32596) 2026-09-01 15:17:03 -05:00
Aiden Cline c066339d80 test(opencode): guard patched dependency versions (#46673) 2026-09-01 14:59:45 -05:00
Aiden Cline 1542195217 fix(opencode): allow none reasoning effort in Bedrock SDK (#46671) 2026-09-01 14:57:55 -05:00
Filip af1f9e6269 remove azure discovery stuff (#46666) 2026-09-01 21:53:43 +02:00
opencode-agent[bot] 2961956c74 chore: update nix node_modules hashes 2026-09-01 19:53:04 +00:00
Aiden Cline 3f39a329c3 feat(opencode): tolerate Anthropic thinking block binding (#46653) 2026-09-01 14:32:26 -05:00
opencode-agent[bot] dc8753f82a chore: update nix node_modules hashes 2026-09-01 19:11:11 +00:00
KevinZhouandAiden Cline 4502ee568e fix(core): bump @ai-sdk/amazon-bedrock to 4.0.166 for reasoning and replay fixes (#45520)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-01 13:52:35 -05:00
Aiden Cline 55c54d14b8 chore: use native runtime conditions in development (#46644) 2026-09-01 13:50:55 -05:00
Aiden Cline 2da5a4b034 refactor(tui): use OpenTUI Dynamic in session view (#46649) 2026-09-01 13:47:49 -05:00
Aiden Cline 216ba8f05f fix(opencode): stop Azure model discovery from logging to stdout (#46646) 2026-09-01 13:39:45 -05:00
opencode-agent[bot] df6aecdbc5 chore: generate 2026-09-01 17:14:51 +00:00
Adam 1ce281b7ab fix(stats): prevent comparison legend collapse 2026-09-01 12:12:22 -05:00
Victor Navarro 5341a5e442 feat(console): add workspace migration timestamp (#46627) 2026-09-01 18:20:52 +02:00
Jack ebece6efd7 docs(web): update Qwen3.7 Max Go usage (#46555) 2026-09-01 15:11:17 +08:00
Dax Raad 1ead9e3d7f fix(web): number Go usage requirements 2026-08-31 22:45:35 -04:00
Dax Raad be3b703a7b fix(web): restore documentation list markers 2026-08-31 22:45:35 -04:00
Kyle Altendorf f7da00f35e fix(opencode): omit empty apply patch move path (#45329) 2026-08-31 21:37:19 -05:00
5c5c709fee fix(tui): pin diff highlights query (#46519)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:16:05 -05:00
opencode-agent[bot] 2386fcec75 chore: generate 2026-09-01 01:35:29 +00:00
Dax Raad ba790579ea docs on proper usage of OpenCode Go 2026-08-31 21:34:02 -04:00
opencode-agent[bot] 04284921ac chore: generate 2026-08-31 17:33:52 +00:00
Adam b639de07ac fix(stats): merge deepseek flash variants (#46446) 2026-08-31 12:32:22 -05:00
opencode-agent[bot]andKit Langton 26ff3ed3d3 fix(tui): keep home shortcuts right-aligned (#36906)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-31 12:01:31 -04:00
opencode-agent[bot]andBrendonovich 9f69463f1d fix(app): backport session rename and tab menu fixes to v1 (#46116)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 05:19:51 +00:00
Jack 10765ff2a9 fix: remove Hy3 Free docs and correct Go chart rendering (#46221) 2026-08-30 12:09:56 +08:00
Jack be53e17e19 docs(go): end Hy3 usage promotion (#46213) 2026-08-30 11:34:46 +08:00
opencode-agent[bot] dc4449df0d chore: update nix node_modules hashes 2026-08-29 02:34:49 +00:00
Kit Langton 62a2f0e617 feat(console): animate Go usage allowances and bonuses (#46055) 2026-08-29 02:22:28 +00:00
Jack df35e842f5 docs(zen): add Ling 3.0 Flash Fin Free (#45923) 2026-08-28 20:04:58 +08:00
Jack 1be9fd55a9 docs(go): add Hy4 preview (#45904) 2026-08-28 18:21:28 +08:00
opencode 755ebdb94e sync release versions for v1.18.25 2026-08-28 05:58:17 +00:00
opencode-agent[bot]andHona c2e39bb556 test(opencode): use native config path in permission assertion (#45849)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-28 05:52:06 +00:00
opencode-agent[bot]andHona 733562e92a fix(opencode): remove Bun dependency from Azure authentication (#45845)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-28 15:38:25 +10:00
Jack 8a7cc0c0ff docs(go): add Qwen3.8 Flash (#45836) 2026-08-28 13:17:02 +08:00
opencode 19db518e0a sync release versions for v1.18.24 2026-08-28 04:10:10 +00:00
opencode-agent[bot]andjlongster 15537a41d2 fix(opencode): compare config snapshots as JSON (#45784)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-27 20:08:12 -04:00
opencode-agent[bot]andneriousy 790fb5b86f feat(opencode): support Azure CLI authentication (#45079)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 01:50:15 +02:00
opencode-agent[bot]andneriousy 517ee736b3 fix(provider): filter unreplayable Bedrock reasoning before caching (#45769)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 00:48:48 +02:00
opencode-agent[bot] c77100a40c chore: generate 2026-08-27 20:05:43 +00:00
James Long 03afae5b95 feat(opencode): load supported v2 config in v1 (#45421) 2026-08-27 16:03:36 -04:00
opencode-agent[bot]andjayair 05ea5073be fix(console): improve Go comparison chart on mobile (#45044)
Co-authored-by: jayair <53023+jayair@users.noreply.github.com>
2026-08-27 13:21:00 -04:00
opencode-agent[bot] 5f5ea53afb chore: generate 2026-08-27 11:13:37 +00:00
Adam 1120d0704e fix(stats): map ox alpha to glm 5.3 flash (#45542) 2026-08-27 06:12:21 -05:00
opencode-agent[bot]andMrMushrooooom 6568a82455 fix(console): merge duplicate Go usage rows (#45503)
Co-authored-by: MrMushrooooom <19261047+MrMushrooooom@users.noreply.github.com>
2026-08-27 03:11:56 -04:00
Adam c2eacd72af fix(console): secure server action redirects (#45374) 2026-08-26 15:03:16 -05:00
Adam c5ef753d28 fix(stats): align retention columns 2026-08-26 11:14:49 -05:00
Adam 530535c6ea fix(stats): reduce retention query scan 2026-08-26 10:38:12 -05:00
Adam 023620b57e chore: add sst unlock workflow 2026-08-26 09:39:34 -05:00
Adam 902e67eba9 feat(stats): add weekly retention 2026-08-26 09:02:23 -05:00
Jack 830aaf2059 docs(go): add GLM-5.3-Flash (#45269) 2026-08-26 21:49:06 +08:00
Adam a0f36c9df7 feat(stats): add retention metrics 2026-08-26 07:39:44 -05:00
opencode-agent[bot] 1216c55094 chore: generate 2026-08-26 11:20:31 +00:00
Jack ec25388937 docs: remove Ox Alpha Free (#45221) 2026-08-26 19:19:12 +08:00
opencode-agent[bot] c7134cbb01 chore: update nix node_modules hashes 2026-08-26 10:55:43 +00:00
Adam ba4d0ea8bf fix(console): validate auth redirects (#45027) 2026-08-26 05:41:23 -05:00
opencode-agent[bot] 1cc53890dc chore: update nix node_modules hashes 2026-08-26 07:47:41 +00:00
Frank ae2ea3c723 fix map inaccuracy 2026-08-26 03:40:35 -04:00
Frank 3f31551fad fix map inaccuracy 2026-08-26 03:31:48 -04:00
Frank 13c27598d3 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-26 02:31:35 -04:00
Frank 2564a4f172 remove map 2026-08-26 02:31:32 -04:00
Ravitez Dondeti fd9bd448a2 docs: mention Exa and Parallel as web search backends (#38395) 2026-08-25 19:57:17 -05:00
opencode-agent[bot]andneriousy b72b50006b fix(core): recover legacy database migration history (#45061)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 21:58:35 +02:00
Jack ac1c048e64 docs(go): add Grok 4.6 (#45042) 2026-08-26 01:27:48 +08:00
Dax 8615731d46 fix(console): rate limit checkout session creation (#45007) 2026-08-25 11:25:59 -04:00
OpeOginniandBrendan Allan a7444bf944 fix(ui): restore focus in stacked dialogs (#44928)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 19:05:49 +08:00
Frank 69aaa22793 zen: void invoice of cancelled subscription 2026-08-25 05:26:31 -04:00
opencode-agent[bot] 322e2b9dd5 chore: generate 2026-08-25 09:07:56 +00:00
Frank afd6f3b064 zen: display quota usage breakdown 2026-08-25 05:06:33 -04:00
Frank 1e86be2bc5 update inference headers 2026-08-25 04:22:20 -04:00
Nathan ThomassinandBrendan Allan a57230b80b fix(app): drop archived sessions from home list right away (#44905)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 07:34:24 +00:00
Frank 6bb1a76e58 update inference headers 2026-08-25 03:30:20 -04:00
Frank bdcb6be649 update inference headers 2026-08-25 03:14:21 -04:00
opencode 2f36ffe35d sync release versions for v1.18.23 2026-08-25 06:30:46 +00:00
Frank 31c409a865 update inference headers 2026-08-25 01:54:38 -04:00
Dax Raad d0ceaef6aa docs(console): prohibit abusive multi-account use 2026-08-24 23:32:44 -04:00
Charlie GleasonandClaude Opus 4.8 3ef72fe8f6 fix(provider): route non-native Cloudflare AI Gateway providers via the REST API (#44828)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 22:01:17 -05:00
51070b6f59 docs: clarify prompt data handling (#44854)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Dax <mail@thdxr.com>
2026-08-24 22:31:47 -04:00
Filip 18b4cb6819 docs(github): correct action token configuration (#44793) 2026-08-25 00:29:56 +02:00
Frank 0561bac189 add client header replacement 2026-08-24 17:40:33 -04:00
Filip f4019cab3e fix(github): support immutable OIDC subjects (#44776) 2026-08-24 23:23:22 +02:00
Charlie GleasonandClaude Sonnet 5 f8b4dd70ac fix(provider): send Anthropic's dashed native slug through the AI Gateway (#44281)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:15:26 -05:00
opencode-agent[bot]andrekram1-node 611cc73d84 fix(opencode): send parent session header (#44752)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 15:24:47 -05:00
Frank 7cde8329bc update model parser 2026-08-24 13:19:53 -04:00
Frank be15db5861 Revert "delay removing first month discount"
This reverts commit 754bb7e390.
2026-08-24 12:07:27 -04:00
opencode 55f984126c sync release versions for v1.18.22 2026-08-24 14:37:14 +00:00
opencode-agent[bot] 2a6be0a03b chore: generate 2026-08-24 12:50:53 +00:00
Dax 2a36236132 fix(opencode): normalize upgrade endpoint (#44686) 2026-08-24 12:49:33 +00:00
opencode-agent[bot]andnexxeln 105b398c2a docs(acp): update Zed custom agent config (#44658)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-24 16:22:48 +05:30
opencode-agent[bot] 9fa27bd41c chore: generate 2026-08-24 10:04:12 +00:00
Jack 6bb772215b docs(go): add LongCat-2.0 (#44636) 2026-08-24 18:02:51 +08:00
Jack 03521003fa docs(go): clarify DeepSeek weekend pricing (#44637) 2026-08-24 16:21:37 +08:00
Frank 754bb7e390 delay removing first month discount 2026-08-24 04:00:55 -04:00
Frank 4371298fff fix(console): discontinue first-month Go discount (#44633) 2026-08-24 03:54:38 -04:00
opencode-agent[bot] 3f2e0e89ec chore: generate 2026-08-24 07:18:00 +00:00
Frank 4dbeeddc77 discontinue first month discount 2026-08-24 03:01:02 -04:00
Frank 4161695885 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-24 02:19:03 -04:00
Frank 7bbfe425f6 always allow ox alpha in go 2026-08-24 02:18:34 -04:00
Frank f2a1d547f1 fix(console): set duplex for streamed zen requests 2026-08-24 02:13:33 -04:00
b1ce938ebe fix(console): support DeepSeek weekend pricing (#44305)
Co-authored-by: MrMushrooooom <MrMushrooooom@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-08-24 01:54:31 -04:00
opencode-agent[bot] 03bba464d4 chore: generate 2026-08-23 17:15:36 +00:00
Dax 63a883a4f7 refactor(console): stream zen bodies without modifiers (#44472) 2026-08-23 13:14:18 -04:00
Dax b3bad6b581 fix(console): revert scanned zen request streaming (#44470) 2026-08-23 13:03:20 -04:00
opencode-agent[bot] ca10088bdf chore: generate 2026-08-23 16:51:36 +00:00
Dax dd3f915956 fix(console): release streamed zen prefixes (#44465) 2026-08-23 12:50:18 -04:00
opencode-agent[bot] c11c41bd86 chore: generate 2026-08-23 16:48:45 +00:00
Dax fa117558ee fix(console): scan zen model before streaming (#44463) 2026-08-23 12:47:29 -04:00
Frank 17b47301ba feat(console): add unblock workspace action 2026-08-23 12:46:02 -04:00
Dax 3d31e4bcec fix(console): revert streamed zen request bodies (#44444) 2026-08-23 11:54:07 -04:00
opencode-agent[bot] bb72277407 chore: generate 2026-08-23 15:19:54 +00:00
Dax 32c3637da0 fix(console): stream zen request bodies (#44429) 2026-08-23 11:18:38 -04:00
Dax Raad 9d466cd849 fix(stats): normalize model casing 2026-08-23 10:33:11 -04:00
opencode-agent[bot] e3bd6e0947 chore: generate 2026-08-23 14:00:45 +00:00
Dax dc13c6bb3d fix(console): reduce zen request memory (#44403) 2026-08-23 09:59:29 -04:00
Brendan Allan 3a31c4ea80 fix(app): keep model provider headers visible (#44115) 2026-08-22 20:50:02 +08:00
Kit Langton e00890c672 fix: resolve console device URLs (#44029) 2026-08-22 00:25:56 +00:00
joelstucki-tauliaandJoel Stucki 3a4c253969 fix(provider): guard textVerbosity injection for @ai-sdk/openai-compatible providers (#43915)
Co-authored-by: Joel Stucki <joel.stucki@example.com>
2026-08-21 22:19:33 +00:00
Adam 34a83b27c3 fix(stats): improve chart tooltip truncation 2026-08-21 17:13:13 -05:00
opencode-agent[bot] ff3ef6e3e6 chore: update nix node_modules hashes 2026-08-21 21:54:44 +00:00
opencode-agent[bot]andneriousy e2ec62d073 fix: bump Amazon Bedrock provider (#43909)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-21 23:39:17 +02:00
opencode-agent[bot]andSlickstef11 bcf1103a8c docs(zen): update GPT 5.6 Sol pricing (#43986)
Co-authored-by: Slickstef11 <98915060+Slickstef11@users.noreply.github.com>
2026-08-21 17:00:03 -04:00
Dax Raad 9cb6fb6529 fix(console): cache desktop downloads 2026-08-21 16:58:52 -04:00
opencode-agent[bot]andthdxr 1b937c860b test(opencode): align unknown finish coverage (#43895)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-21 10:56:50 -04:00
opencode ad0bb6d9a3 sync release versions for v1.18.21 2026-08-21 14:51:08 +00:00
opencode-agent[bot] 487d584402 docs(zen): reflect GPT 5.6 Sol discount (#43874) 2026-08-21 22:49:25 +08:00
opencode-agent[bot]andrekram1-node 57fa34f235 fix(opencode): continue unknown finish responses (#43892)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-21 10:30:15 -04:00
Adam 0af9dd61d2 fix(stats): merge renamed model data (#43883) 2026-08-21 08:38:31 -05:00
opencode-agent[bot] 8ecd4c21bf chore: generate 2026-08-21 13:24:26 +00:00
361a71ffad fix(opencode): route Vertex multi-regions through REP (#42648)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-21 15:23:05 +02:00
opencode-agent[bot] fa8f17014c chore: generate 2026-08-21 12:58:43 +00:00
Jack 813e6f3cec docs(go): add DeepSeek vision model (#43866) 2026-08-21 20:57:25 +08:00
Jack ba72a6ff2b fix(go): align Ox Alpha model ID (#43837) 2026-08-21 17:03:23 +08:00
Brendan Allan b6a1f95858 fix(app): preserve file search results while loading (#43836) 2026-08-21 08:52:45 +00:00
Nathan Thomassin f357e70779 fix(app): register archive session command in both layouts (#41741) 2026-08-21 08:28:40 +00:00
opencode a9cac91d60 sync release versions for v1.18.20 2026-08-21 08:09:28 +00:00
Aiden Cline 35fe5b7212 fix(opencode): surface subagent tool errors (#43821) 2026-08-21 02:44:44 -05:00
opencode-agent[bot]andAiden 62cb3f77bd test(opencode): remove flaky subagent test (#43819)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 02:35:25 -05:00
opencode-agent[bot]andAiden e0b9e68a68 fix(opencode): retry raw network finish errors (#43813)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 02:17:35 -05:00
opencode-agent[bot] 1e4c153aed chore: generate 2026-08-21 07:07:33 +00:00
Jack c9dc1a1b3d feat(go): add Ox Alpha to usage graph (#43809) 2026-08-21 15:06:23 +08:00
opencode-agent[bot]andAiden 40282c1d4d fix(opencode): retry network error variants (#43806)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 01:47:20 -05:00
opencode-agent[bot] e11dbd0206 chore: generate 2026-08-21 06:25:22 +00:00
Jack 7b11ef5a5e docs(go): add Ox Alpha Free (#43798) 2026-08-21 14:24:06 +08:00
opencode-agent[bot] 2859603cbb chore: generate 2026-08-21 02:58:48 +00:00
opencode-agent[bot]andrekram1-node c313504c82 fix(opencode): surface resumable subagent errors (#43657)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 21:57:33 -05:00
e49772a8b4 fix(opencode): preserve Cerebras completion limit (#43736)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: ryanl-cerebras <230249923+ryanl-cerebras@users.noreply.github.com>
2026-08-20 19:30:30 -05:00
opencode-agent[bot]andrekram1-node 08faeb3893 fix(opencode): answer subagent permissions in run (#43675)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 19:29:32 -05:00
Frank 5e75e5e990 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-20 14:53:36 -04:00
Frank 5cc5b622f0 update muse country list 2026-08-20 14:53:34 -04:00
Jack 11e8110f9e fix(docs): align Ox Alpha free model ID (#43695) 2026-08-21 01:49:11 +08:00
Jack 82dee24302 docs(zen): add Ox Alpha free model (#43690) 2026-08-21 01:31:56 +08:00
Adam ad192a59b5 fix(stats): clarify market share providers (#43647) 2026-08-20 10:46:24 -05:00
opencode-agent[bot]andrekram1-node 71d08e94d5 fix(opencode): retry xAI capacity stream errors (#43640)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 09:29:30 -05:00
opencode b155b15694 sync release versions for v1.18.19 2026-08-20 06:22:03 +00:00
Frank d06bf487a5 sync 2026-08-20 02:19:05 -04:00
Frank ccccd75328 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-20 02:08:32 -04:00
Frank 03d65f36d3 sync 2026-08-20 02:08:22 -04:00
Aiden Cline f4a89683da fix(tui): remove encrypted label from thought header (#43588) 2026-08-20 00:46:39 -05:00
Frank f715478a13 doc muse regions 2026-08-20 01:43:59 -04:00
e0e9bd7d5f fix(tui): show encrypted reasoning status (#43578)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-20 00:23:20 -05:00
opencode-agent[bot]andthdxr 5ccb1e88e1 fix(stats): normalize routed model ids (#43582)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-20 01:12:18 -04:00
Frank be469f328c sync 2026-08-20 00:42:47 -04:00
opencode-agent[bot] fc80874f45 chore: generate 2026-08-20 02:49:11 +00:00
Frank 3d26b12251 zen: muse spark contributor 2026-08-19 22:47:41 -04:00
Frank 1b77242d40 country check for muse spark in free 2026-08-19 22:44:35 -04:00
Adam 4c5960dc4b feat(stats): include free tier usage 2026-08-19 19:39:23 -05:00
Frank e2505d434a Revert "update go models"
This reverts commit 16af134053.
2026-08-19 15:03:41 -04:00
Frank a466a357b6 Revert "chore: generate"
This reverts commit 6386e67949.
2026-08-19 15:03:39 -04:00
opencode-agent[bot] 6386e67949 chore: generate 2026-08-19 17:11:33 +00:00
Frank 16af134053 update go models 2026-08-19 13:09:39 -04:00
Jack d545d8fba5 feat(go): promote Hy3 usage (#43429) 2026-08-19 21:36:12 +08:00
opencode-agent[bot] da4730e4a4 chore: generate 2026-08-19 06:36:52 +00:00
Frank 18fc3eea7c update zen model name 2026-08-19 02:35:19 -04:00
opencode-agent[bot] 101ff6d1a2 chore: generate 2026-08-19 04:53:24 +00:00
Jack 20ad497bc8 docs(console): end Luna usage promotion (#43351) 2026-08-19 12:52:09 +08:00
opencode-agent[bot] 1b18a50418 chore: generate 2026-08-19 03:58:10 +00:00
Frank 0a6397272c Merge branch 'muse-spark' into dev 2026-08-18 23:56:22 -04:00
Frank 3477d28a87 Merge branch 'muse-spark' of github.com:anomalyco/opencode into muse-spark 2026-08-18 23:55:07 -04:00
Frank fb772e8a02 sync 2026-08-18 23:54:57 -04:00
Frank 4e29e04f24 sync 2026-08-18 23:52:44 -04:00
Frank fbcd4faa61 sync 2026-08-18 23:48:49 -04:00
Frank da3ba63872 sync 2026-08-18 23:42:23 -04:00
Frank e2a2bc301d sync 2026-08-18 23:13:41 -04:00
Frank 72824fe65d sync 2026-08-18 23:11:12 -04:00
Frank 64b4f7df4c sync 2026-08-18 22:55:47 -04:00
bhuvankakkar 7774461bbf docs: add SCX.ai to the providers list (#42520) 2026-08-18 21:11:56 -05:00
opencode-agent[bot]andrekram1-node 8b65fa2ef6 fix(opencode): remove Qwen sampling defaults (#43310)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-18 17:59:59 -05:00
Filip 0033bb3559 fix(core): restore session request headers (#43188) 2026-08-18 21:27:28 +02:00
OpeOginni ad905f8e6c fix(opencode): properly show authed providers on /connect command (#39915) 2026-08-18 18:48:25 +00:00
Shoubhit Dash 9b0dd36cda fix(session): ignore malformed model costs (#43248) 2026-08-18 19:49:08 +05:30
AdamandFrank 4e81a0b73f fix(console): preserve inference sessions (#43124)
Co-authored-by: Frank <frank@anoma.ly>
2026-08-18 00:48:25 -04:00
opencode-agent[bot]andLuke Parker 32320409b1 fix(app): keep server details editable (#43169)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 03:38:06 +00:00
Dax Raad 040b856140 fix(cli): stop legacy preview publishing 2026-08-17 23:08:34 -04:00
opencode-agent[bot] 65c35977bd chore: update nix node_modules hashes 2026-08-17 19:25:35 +00:00
opencode-agent[bot]andAiden Cline 57075d8cdc fix(provider): update Google Vertex SDK (#43108)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 14:11:23 -05:00
Frank e14acea581 update ds flash limit 2026-08-17 14:54:03 -04:00
Filip 7af274a921 fix(core): fall back on oversized websocket requests (#43099) 2026-08-17 20:36:00 +02:00
Filip a97fec8af5 fix: codex data residency (#42432) 2026-08-17 19:03:28 +02:00
opencode-agent[bot]andVictor Navarro 2cba7e227d fix(cli): update default console URL (#43043)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-08-17 13:31:33 +02:00
Game On 4d68d30b48 tweak: match codex limits for openai models exactly when using chatgpt subscription (#39082) 2026-08-17 00:39:52 -05:00
opencode-agent[bot] 5a0e07efcf chore: generate 2026-08-17 04:54:34 +00:00
Aiden ClineandKeefe Tang cba6b5f2f7 feat(opencode): native OpenAI and Anthropic passthroughs for Cloudflare AI Gateway (#42634)
Co-authored-by: Keefe Tang <keefe@pentagram.me>
2026-08-16 23:53:20 -05:00
opencode-agent[bot]andFilip 1c965451b5 fix(stats): correct YouTube footer link (#42941)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-16 23:58:45 +02:00
Jack a0f8dccbfe docs: update DeepSeek V4 pricing (#42881) 2026-08-17 00:01:15 +08:00
Dax Raad fb8344f3c2 chore: remove scheduled beta sync 2026-08-16 11:26:41 -04:00
Frank 3fd77ae980 zen: peak pricing 2026-08-16 05:20:04 -04:00
Jack 976c185172 docs(go): remove DeepSeek Flash promotion (#42858) 2026-08-16 14:08:47 +08:00
opencode-agent[bot]andAiden Cline 4643e65ad6 fix(opencode): enable web search for Go (#42630)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-14 13:23:26 -05:00
Jack e23586af26 feat(go): add GLM 5.3 (#42518) 2026-08-14 13:48:32 +08:00
opencode-agent[bot] 92d29ba4a3 chore: generate 2026-08-14 04:17:00 +00:00
Jack 886fd98f52 docs(zen): add Muse Spark 1.2 (#42508) 2026-08-14 12:15:43 +08:00
opencode-agent[bot] 722e717e99 chore: update nix node_modules hashes 2026-08-14 03:25:42 +00:00
Aiden ClineandAiden Cline 6d635007ab chore(deps): update ai-gateway-provider to 3.2.0 (#42488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 22:08:45 -05:00
opencode-agent[bot]andStefan Avram 0e3474509a docs: sort Gemini 3.7 before 3.6 (#42473)
Co-authored-by: Stefan Avram <98915060+Slickstef11@users.noreply.github.com>
2026-08-14 10:24:22 +08:00
Dax d8bf79225f fix(opencode): preserve v1 database compatibility (#42444) 2026-08-14 00:05:15 +00:00
opencode-agent[bot] 8a55ba75b5 chore: generate 2026-08-13 18:57:31 +00:00
Jack 2449581543 fix(go): remove Gemini 3.7 Flash (#42393) 2026-08-14 02:55:24 +08:00
opencode-agent[bot] 3e25e80f7a chore: generate 2026-08-13 18:44:28 +00:00
Jack f06e9491e1 feat(go): add Gemini 3.7 Flash (#42390) 2026-08-14 02:41:12 +08:00
Jack d0c2b41adf docs(go): use responses API for Grok 4.5 (#42373) 2026-08-14 01:28:55 +08:00
Frank c7af47f9ed update grok endpoint 2026-08-13 13:27:41 -04:00
Kit Langton 6c035e1fd7 fix(core): preserve unicode in grep previews (#42356) 2026-08-13 16:51:43 +00:00
opencode-agent[bot] ab7cbc808f chore: generate 2026-08-13 16:30:12 +00:00
Aditya Sethi 62387f39d4 fix(skills): Update global config path in documentation (#42337) 2026-08-13 18:27:29 +02:00
Jack 864889ab9f docs: remove Ling 3.0 Tiny free model (#42314) 2026-08-13 20:48:54 +08:00
opencode cc4b456129 sync release versions for v1.18.18 2026-08-13 01:15:01 +00:00
opencode-agent[bot] 14b37df391 chore: update nix node_modules hashes 2026-08-12 22:36:40 +00:00
opencode-agent[bot]andAiden Cline 91df883231 fix(opencode): select Kimi prompt by provider (#42161)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:29:03 -05:00
opencode-agent[bot]andAiden Cline 6fea419feb fix(groq): pass through reasoning effort (#42166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:20:23 -05:00
opencode-agent[bot]andAiden Cline beeabe2e4b fix(mistral): pass through reasoning effort (#42164)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:20:07 -05:00
opencode-agent[bot]andAiden Cline 502310f4df fix(xai): pass through reasoning effort (#42160)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:19:17 -05:00
opencode 37fe5c83dc sync release versions for v1.18.17 2026-08-12 20:25:04 +00:00
Aiden Clineandakenra dab2637217 fix(compaction): adjust instructions and structure to be more clear to smaller models like dsv4 flash (#42045)
Co-authored-by: akenra <37288280+akenra@users.noreply.github.com>
2026-08-12 14:36:51 -05:00
opencode-agent[bot]andAiden Cline 39fb919a05 chore: add neriousy to team members (#42107)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 13:30:38 -05:00
opencode-agent[bot] 999be62662 chore: generate 2026-08-12 16:56:04 +00:00
opencode-agent[bot]andDax Raad 521906f5fa docs(go): clarify DeepSeek ZDR coverage (#42085)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-12 12:53:58 -04:00
Frank 069165eb5b Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-12 12:48:10 -04:00
Frank df09c3ec61 update ds v4 pro 2026-08-12 12:48:08 -04:00
opencode-agent[bot] 6d3ae4d63d chore: generate 2026-08-12 16:47:08 +00:00
Adam 284187ac55 fix(ci): authenticate pulumi downloads 2026-08-12 11:45:00 -05:00
Adam 7e0353cca9 fix(stats): correct r2 daily totals 2026-08-12 11:36:23 -05:00
SKY ZHAOandskyzhao1223 959c8bd498 docs: fix provider display name and PAT typos (#42034)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:32:23 -05:00
SKY ZHAOandskyzhao1223 ca3df21b7f docs: fix broken DigitalOcean and Daytona links (#42048)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:31:47 -05:00
Matthew Feroz 8571a922db fix(provider): add Merge Gateway reasoning variants (#41867) 2026-08-12 10:31:19 -05:00
Frank d92d1e654b docs(zen): add Grok 4.6 2026-08-12 10:53:38 -04:00
Adam 46a14e685a feat(stats): query r2 data catalog 2026-08-12 09:49:10 -05:00
Jack 1f94d8a3c8 docs(zen): remove expired free models (#41943) 2026-08-12 12:03:45 +08:00
Aiden Cline c78986831c fix(opencode): cap session retries with jitter (#41939) 2026-08-11 22:50:52 -05:00
vimtor d470434746 refactor(console): simplify go usage response 2026-08-11 23:18:57 +02:00
Steven Ao 561afb401a fix(opencode): detect Copilot PDF input support (#41522) 2026-08-11 16:04:36 -05:00
Arif Rahman (Bolt)andvimtor 2b8a5969e9 feat(console): add go usage endpoint (#16513)
Co-authored-by: vimtor <vn4varro@gmail.com>
2026-08-11 22:25:23 +02:00
Jack 36b205370d docs(zen): add Hy3 Free (#41814) 2026-08-12 02:42:06 +08:00
6afef2f881 fix(console): update blocked account message (#41819)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
2026-08-11 14:41:37 -04:00
opencode-agent[bot] 0d927ba03f chore: generate 2026-08-11 14:17:42 +00:00
Jack 9fdd4824d3 docs(zen): add Nemotron 3.5 Lightning (#41750) 2026-08-11 22:15:53 +08:00
opencode-agent[bot]andLuke Parker d041eee55c chore: use DeepSeek for beta conflicts (#41669)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 04:58:15 +00:00
Luke Parker d62e50075a chore: build beta from v2 (#41627) 2026-08-11 14:33:42 +10:00
5d953482ab fix(provider): scope DeepSeek V4 Flash sampling defaults (#41620)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: 侯秀凯 <9627034+hoooou@users.noreply.github.com>
2026-08-10 23:01:56 -05:00
OpeOginni 3a90639cb5 fix(ui): correct OC-2 weak icon color (#41504) 2026-08-10 17:08:45 -05:00
Matthias Reso b9f3b382fc fix(session): route all Muse family models to the Meta system prompt (#41581) 2026-08-10 13:28:04 -05:00
opencode-agent[bot]andLuke Parker 550d1ffd24 fix(i18n): use widely recognized developer terminology (#41532)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-10 22:00:00 +10:00
Brendan Allan 941e71dbbb fix(app): use current default model (#38603) 2026-08-10 15:14:17 +08:00
opencode d90532a595 sync release versions for v1.18.16 2026-08-10 06:07:05 +00:00
opencode-agent[bot] 0bff28de09 fix(stats): fall back after full sync failure (#41411) 2026-08-09 10:58:58 -05:00
opencode-agent[bot] 38e10eb140 fix(opencode): ignore unknown config fields (#41312) 2026-08-08 14:53:43 -04:00
opencode-agent[bot] fe82a1b6ca chore: generate 2026-08-08 03:39:08 +00:00
Brendan Allan 2ea728f731 fix(app): populate project picker from home (#41158) 2026-08-08 11:37:30 +08:00
Aarav Sareen 284214c78d feat(app): open project menu on right-click (#41013) 2026-08-07 17:25:46 +08:00
日常无语的无语者 b05a0d1fbe fix(i18n): use 词元 instead of 令牌 for token in zh locale (#40977) 2026-08-07 19:22:23 +10:00
Jack be25c905fa docs(zen): replace Ling Flash with Tiny (#41027) 2026-08-07 16:48:42 +08:00
Brendan Allan 5347b5e009 fix(app): register new home projects (#41018) 2026-08-07 07:34:32 +00:00
opencode-agent[bot]andBrendan Allan b467518dae fix(desktop): preserve macOS app on window close (#40974)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-07 07:25:12 +00:00
opencode 1ec6bdc8c6 sync release versions for v1.18.15 2026-08-07 06:49:53 +00:00
Aarav Sareen 265472ef72 fix(ui): use tokens for button loading state (#40884) 2026-08-07 12:11:00 +05:30
Frank 325529761b sync 2026-08-07 02:22:25 -04:00
opencode-agent[bot]andLuke Parker 741244b69d feat(app): add global locale coverage (#40992)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 05:41:11 +00:00
Jack 4abddbbfdd feat(go): promote DeepSeek V4 Flash usage (#40988) 2026-08-07 13:24:03 +08:00
opencode-agent[bot]andDax 5aa5cb3523 fix(app): use chronological message boundaries (#41006)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:23:28 +00:00
opencode-agent[bot]andDax 9113255114 fix(app): order stored messages by creation time (#41001)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:06:28 +00:00
opencode-agent[bot]andDax 23cc677108 fix(tui): order messages by creation time (#40994)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:41:15 +00:00
opencode-agent[bot]andDax 28bcc0e4f4 fix(app): sort sessions by persisted time (#41000)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:23:04 +00:00
Dmitry NefedovandAiden Cline 8bf5062b89 feat(tui): add cursor style configuration (#32295)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 23:20:51 -05:00
opencode-agent[bot]andDax 20750c332e fix(web): order shared messages by creation time (#40995)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:11:28 +00:00
opencode-agent[bot]andDax db581e47a3 fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:23 +00:00
opencode-agent[bot]andDax a54a693af2 fix(opencode): use chronological message boundaries (#40991)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:02 +00:00
opencode-agent[bot]andDax d468201952 fix(opencode): use file times for truncation cleanup (#40987)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 03:58:31 +00:00
opencode-agent[bot]andLuke Parker 31c5c4e924 fix(app): keep locale names native (#40985)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 03:32:43 +00:00
opencode-agent[bot]andHona 8d65dbdd04 fix(app): complete translation coverage (#40981)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-07 03:06:23 +00:00
opencode-agent[bot]andAiden Cline b7f9363393 fix(opencode): serialize orphaned compaction history (#40800)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 21:11:14 -05:00
opencode-agent[bot] 69f2cbaa3a chore: generate 2026-08-06 23:37:25 +00:00
Frank dc898ca2c3 sync 2026-08-06 19:34:26 -04:00
Adam 03bff6500a fix(app): update homepage stats 2026-08-06 11:07:33 -05:00
Adam b379d71d19 fix(stats): update github stars 2026-08-06 10:58:58 -05:00
ayuandSimon Klee def7220bfc fix(tui): support copying over ssh with set-clipboard on tmux config (#30472)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-06 10:23:32 +02:00
opencode-agent[bot]andLuke Parker f6c5afe5a9 fix(desktop): disable packaged console logging (#40794)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-06 17:25:41 +10:00
Frank 847771fe06 fix log processor 2026-08-06 03:21:16 -04:00
opencode-agent[bot] b8bd88901a chore: generate 2026-08-06 01:52:15 +00:00
Luke Parker f1adabcddc feat(app): export session as json from ui (#40781) 2026-08-06 11:50:33 +10:00
Luke Parker 24470e52a5 fix(desktop): embed version in server sidecar (#40764) 2026-08-06 07:22:11 +08:00
mridul 23bbc5cd14 fix(server): allow blob attachments in web UI (#40692) 2026-08-05 22:23:32 +00:00
opencode ebf6fc07a1 sync release versions for v1.18.14 2026-08-05 20:58:52 +00:00
opencode-agent[bot] 04513d9692 chore: generate 2026-08-05 20:56:57 +00:00
Adam 146720e197 fix(stats): improve page speed 2026-08-05 15:54:35 -05:00
opencode-agent[bot] b1f8cc04af chore: update nix node_modules hashes 2026-08-05 20:33:25 +00:00
opencode-agent[bot] 082fe93e16 chore: generate 2026-08-05 20:16:40 +00:00
Aiden Cline 709c195905 fix(opencode): preserve compatible stream errors (#40718) 2026-08-05 15:14:03 -05:00
opencode-agent[bot] 3355b78d91 chore: generate 2026-08-05 20:06:05 +00:00
Adam b84c63d034 fix(stats): reduce html payloads 2026-08-05 15:02:39 -05:00
opencode-agent[bot] 057b5a9dee chore: generate 2026-08-05 19:41:32 +00:00
Aiden Cline 61aefc0759 fix(opencode): expand retryable error patterns (#40707) 2026-08-05 14:39:49 -05:00
Aiden Cline f929f8f100 refactor(opencode): simplify retry error matching (#40694) 2026-08-05 13:05:19 -05:00
opencode-agent[bot]andLuke Parker 4a57013cf8 fix(app): show pending tool details (#40603)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 08:25:06 +00:00
Jack 2f17fc9613 docs(zen): add LongCat free model (#40585) 2026-08-05 12:49:28 +08:00
opencode-agent[bot] b8ea3ea091 chore: generate 2026-08-05 02:33:08 +00:00
Luke Parker 82a5796159 test(app): harden flaky e2e synchronization (#40556) 2026-08-05 12:31:45 +10:00
opencode-agent[bot]andAiden Cline 9f38562237 fix(opencode): include cache writes in ACP usage (#40450)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 21:27:01 -05:00
opencode-agent[bot] 5b4fb1f770 chore: generate 2026-08-05 02:20:04 +00:00
Aiden Cline cb88db6ce3 tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (#40537) 2026-08-04 21:18:44 -05:00
Luke Parker 66fdd51f0d docs: add RTL development skill (#40543) 2026-08-05 01:01:10 +00:00
opencode-agent[bot]andLuke Parker 98dd65cd60 fix(app): collapse deletion-only edit parts (#40536)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 00:23:06 +00:00
James MurdzaandClaude Opus 4.8 f0afb6750e fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James MurdzaandClaude Opus 4.8 703d09f306 fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
opencode aefaf140c1 sync release versions for v1.18.13 2026-08-04 12:12:30 +00:00
Shoubhit Dash 44614c79c4 fix(acp): drain updates before end turn (#40422) 2026-08-04 17:26:33 +05:30
Amy Duquette dc67b7a3be docs: add Poolside provider setup (#39187) 2026-08-04 06:43:54 -05:00
Luke Parker de1bd36a47 fix(app): correct RTL layout interactions (#40410) 2026-08-04 11:39:32 +00:00
opencode-agent[bot] 6c97a46dae fix(stats): hide zero-usage countries (#40400) 2026-08-04 06:26:48 -05:00
Luke Parker c387fe190b feat(app): add debug layout direction toggle (#40393) 2026-08-04 08:52:37 +00:00
opencode-agent[bot]andLuke Parker 8721a2710b fix(app): complete shared RTL primitives (#40388)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 07:38:56 +00:00
Brendan Allan c75e3b5bae fix(app): supply fallback session titles (#40385) 2026-08-04 15:09:29 +08:00
opencode-agent[bot] edc8d1d91e chore: generate 2026-08-04 06:57:27 +00:00
opencode-agent[bot]andLuke Parker e85735028c feat(i18n): localize hardcoded application copy (#40377)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 16:56:03 +10:00
opencode-agent[bot]andLuke Parker 6c3299103c feat(app): add minimal RTL support (#40372)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 05:08:51 +00:00
opencode-agent[bot] 60e23fee16 chore: generate 2026-08-04 04:38:17 +00:00
opencode-agent[bot]andLuke Parker 0c380f31ac feat(app): support locale plural rules (#40370)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 04:36:57 +00:00
opencode-agent[bot] 26aefa68f5 chore: update nix node_modules hashes 2026-08-04 04:30:14 +00:00
Luke Parker 638788f8d0 fix(app): move markdown parsing to worker (#40356) 2026-08-04 04:13:34 +00:00
opencode-agent[bot] b005a15a4a chore: generate 2026-08-04 03:58:49 +00:00
opencode-agent[bot]andLuke Parker 0efa33cf33 feat(desktop): expand and audit locale translations (#40362)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 03:57:33 +00:00
opencode-agent[bot]andAiden Cline 7e94ddafe8 fix(github): include pull request identity in context (#40363)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-03 22:18:52 -05:00
opencode 7fe993879f sync release versions for v1.18.12 2026-08-04 00:55:26 +00:00
Frank def14d96ef Revert "fix slow queries" 2026-08-03 20:14:17 -04:00
opencode-agent[bot]andAiden Cline b0b1149233 test(opencode): cover Azure completion reasoning (#40340)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 09:50:17 +10:00
Aarav SareenandLukeParkerDev 7becbbf711 feat(app): refine diff viewer (#40285)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-03 22:49:49 +00:00
Frederik 87481f2bb6 fix: gpt-5.5+ in combination with azure fails with reasoningEffort (#40265) 2026-08-03 17:42:30 -05:00
opencode-agent[bot]andAiden Cline 9535a8f929 fix(ui): update OpenRouter logo (#40313)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 07:08:09 +10:00
YB e917c12c80 docs: remove obsolete vouch system section (#40266) 2026-08-03 14:17:27 -05:00
opencode-agent[bot] 89130db6b0 chore: update nix node_modules hashes 2026-08-03 10:07:49 +00:00
Frank 9465a6c97d Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-03 05:54:55 -04:00
Frank 53fd02970d fix slow queries 2026-08-03 05:54:41 -04:00
Luke Parker 6ff0adef22 perf(app): fix composer lag via buffered blob draft storage (#40207) 2026-08-03 19:49:08 +10:00
opencode-agent[bot]andLuke Parker 3307f99f1f fix(ci): reuse imported macOS keychain (#40221)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-03 19:48:32 +10:00
Frank 8de7a3c739 fix slow queries 2026-08-03 05:41:46 -04:00
opencode-agent[bot] 28d0637406 chore: generate 2026-08-03 08:48:38 +00:00
Frank ef7f712867 fix slow queries 2026-08-03 04:47:10 -04:00
Joshua 3016830e25 fix(app): search every known project in the open project dialog (#40202) 2026-08-03 14:54:00 +08:00
Jack e9e7472456 docs(go): add Qwen3.8 Max (#40228) 2026-08-03 14:48:57 +08:00
Brendan Allan 9f9fbf815c fix(app): clear stale timeline errors (#39465) 2026-08-03 14:18:52 +08:00
opencode-agent[bot] c88facb2cc chore: generate 2026-08-03 06:09:39 +00:00
twinkle d4b85f8d83 fix(app): skip legacy config reads for v2 (#40211) 2026-08-03 14:08:22 +08:00
Frank 1882c33827 fix gemini reasoning tokens not counted 2026-08-02 02:31:20 -04:00
opencode-agent[bot] 01624c8c87 chore: generate 2026-08-02 03:36:50 +00:00
Jack 0891ecd8e0 docs(go): update DeepSeek privacy policy (#40120) 2026-08-02 11:35:35 +08:00
Frank 32f278b48f Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-01 10:25:21 -04:00
Frank bb6bec9070 zen: update doc 2026-08-01 10:25:18 -04:00
opencode 0eac0964af sync release versions for v1.18.11 2026-08-01 11:44:43 +00:00
OpeOginni f67e80c275 fix(app): prevent stale prompt control reads (#39842) 2026-08-01 09:46:46 +00:00
opencode-agent[bot] 19231fce4b chore: generate 2026-07-31 17:18:52 +00:00
Frank d0a7ca6d1b go: document data policy per model 2026-07-31 13:16:18 -04:00
Frank d80e86be9d go: document data policy per model 2026-07-31 13:15:27 -04:00
opencode-agent[bot] e4bd9757a3 chore: generate 2026-07-31 12:18:55 +00:00
Jack 4087cf1a97 fix(go): restore limits graph axis (#39870) 2026-07-31 20:17:28 +08:00
opencode-agent[bot] 14f0bf64a1 chore: generate 2026-07-31 08:31:55 +00:00
Frank 2df47ee394 deepseek v4 flash 2026-07-31 04:28:41 -04:00
opencode-agent[bot] ad82616445 chore: update nix node_modules hashes 2026-07-31 08:24:41 +00:00
opencode-agent[bot] d4ad650f73 chore: generate 2026-07-31 08:03:42 +00:00
Luke Parker 2039c90c06 fix(desktop): open external links in system browser (#39820) 2026-07-31 18:02:11 +10:00
Jack da59457ca4 feat(go): add GPT 5.6 Luna content (#39812) 2026-07-31 01:23:25 -04:00
OpeOginni db4dbaa289 fix(app): prevent stale session tab reads (#39767) 2026-07-31 05:14:09 +00:00
opencode-agent[bot]andBrendan Allan d3f30df180 fix(app): open legacy picker at home (#39804)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-31 04:38:34 +00:00
OpeOginni 7ea343cabd fix(app): prevent file tree tab clipping (#39770) 2026-07-31 12:24:19 +08:00
Luke Parker e024e2ef92 fix(app): align debug gutter text (#39782) 2026-07-31 01:46:36 +00:00
Deven Navani ceb4890ca3 docs: add Modal provider setup (#39710) 2026-07-30 13:29:32 -05:00
opencode-agent[bot] 3f239b3834 chore: update nix node_modules hashes 2026-07-30 16:46:38 +00:00
Aiden Cline c1ee3c6e34 fix(opencode): stop MCP SSE error reconnect loops (#39697) 2026-07-30 11:26:48 -05:00
opencode-agent[bot]andAiden Cline a1ab489e61 fix(provider): widen interleaved reasoning fields (#39556)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:10:34 -05:00
opencode 8c38d260eb sync release versions for v1.18.10 2026-07-30 14:43:50 +00:00
opencode-agent[bot]andDax Raad a4f25a94b4 fix: use OpenCode model catalog URL (#39672)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-30 10:12:08 -04:00
opencode-agent[bot] ff0382e971 chore: generate 2026-07-30 09:31:27 +00:00
OpeOginni ac5380614f fix(app): repair malformed persisted tabs (#39645) 2026-07-30 17:29:40 +08:00
opencode-agent[bot] c08350b83e chore: update nix node_modules hashes 2026-07-30 09:11:45 +00:00
Aarav Sareen c5cf416633 feat(app): fix composer attachment fades (#38547) 2026-07-30 17:07:35 +08:00
Aarav Sareen 3dbce3dade feat(app): adds fades to file tree (#38549) 2026-07-30 17:07:06 +08:00
opencode-agent[bot] a277557a5f chore: generate 2026-07-30 09:05:12 +00:00
Aarav Sareen 005bf088c9 feat(app): fix file tree button bg (#38629) 2026-07-30 17:03:23 +08:00
Aarav Sareen eca5e68a5e feat(app): disallow duplicate attachments (#39464) 2026-07-30 17:02:15 +08:00
Aarav SareenandBrendan Allan 5b11635d97 feat(app): always show new session button (#39520)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:58:38 +08:00
Aarav SareenandBrendan Allan a9eda2e99e feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-30 08:57:19 +00:00
Aarav SareenandBrendan Allan 73009f0b09 feat(app): update arrow buttons in review tab top bar (#39469)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:56:16 +08:00
Frank f720490219 fix(console): add public fetch compatibility flag 2026-07-30 00:40:52 -04:00
Deven Navani 341c64cc97 feat: discover Modal models (#39066) 2026-07-29 23:06:19 -05:00
opencode-agent[bot] 1e17856ba4 chore: update nix node_modules hashes 2026-07-29 18:06:26 +00:00
Lakshya Agarwal a6f7fe739c docs(ecosystem): add opencode-tavily plugin (#38709) 2026-07-29 12:52:12 -05:00
Vladimir Glafirov 02f50a6e80 chore: bump gitlab-ai-provider to 6.12.1 (#39490) 2026-07-29 12:45:40 -05:00
opencode-agent[bot] 7565e03536 chore: generate 2026-07-29 08:36:03 +00:00
usrnk1 035a5a1a67 feat(desktop): refine tab states (#39472) 2026-07-29 08:34:37 +00:00
Brendan Allan e8b0992788 fix(app): defer model variant selector (#39445) 2026-07-29 05:16:30 +00:00
opencode-agent[bot] 8cbea4fbb7 chore: generate 2026-07-29 01:26:18 +00:00
Brendan Allan f256a4c538 fix(app): preserve agent picker for existing users (#39300) 2026-07-29 01:24:31 +00:00
opencode a45c2b917e sync release versions for v1.18.9 2026-07-28 18:14:59 +00:00
opencode-agent[bot] f28d72d15e chore: update nix node_modules hashes 2026-07-28 17:12:42 +00:00
opencode-agent[bot] d8a9d8a76d chore: generate 2026-07-28 16:55:30 +00:00
Aiden Cline 982a9044c5 fix(mcp): restore legacy SDK compatibility (#39373) 2026-07-28 11:53:35 -05:00
7edefb3347 chore(mcp): upgrade client to 2.0.0 (#39369)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-28 11:37:58 -05:00
usrnk1 017a5977d2 feat(desktop): remove v2 vertical menu borders (#39317) 2026-07-28 19:50:58 +08:00
opencode-agent[bot] 7336cc5a7b chore: update nix node_modules hashes 2026-07-28 11:21:16 +00:00
opencode-agent[bot] e1587588c4 chore: generate 2026-07-28 11:09:22 +00:00
opencode-agent[bot]andJack be93cec9ba fix(app): guard reentrant Solid cleanup (#39261)
Co-authored-by: Jack <jack@anoma.ly>
2026-07-28 19:07:19 +08:00
7c898249a2 fix(desktop): patch @dnd-kit/solid to preserve core scroll plugins (#38119)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 19:05:57 +08:00
Brendan Allan 9e432a6785 feat(desktop): add opt-in v2 sidecar (#39286) 2026-07-28 17:29:27 +08:00
Brendan Allan 1ead8d84a2 fix(app): localize home session suspense (#39285) 2026-07-28 16:28:59 +08:00
Brendan Allan 8c81f9a40b refactor(app): extract v2 settings controllers (#39228) 2026-07-28 16:12:59 +08:00
usrnk1 95636bd3ca feat(desktop): collapse model provider sections (#39283) 2026-07-28 07:35:57 +00:00
opencode-agent[bot] 7dae9a1083 chore: generate 2026-07-28 07:03:56 +00:00
Brendan Allan d46be565aa refactor(app): thin new session composition (#39227) 2026-07-28 15:02:26 +08:00
opencode 8021dbd80f sync release versions for v1.18.8 2026-07-28 06:07:51 +00:00
opencode-agent[bot] c8487bac54 chore: update nix node_modules hashes 2026-07-28 05:50:46 +00:00
Aiden Cline 484f00ebf4 fix(mcp): recover expired SDK sessions (#39265) 2026-07-28 00:35:19 -05:00
Aiden Cline c3be6c4965 fix(mcp): honor callback port in debug (#39259) 2026-07-28 00:00:38 -05:00
opencode-agent[bot] 172d08cb98 chore: update nix node_modules hashes 2026-07-28 04:34:03 +00:00
opencode-agent[bot]andAiden Cline 5e39051183 fix(provider): omit deprecated Gemini sampling defaults (#38924)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-27 23:21:00 -05:00
opencode-agent[bot] d50b9e8f9a chore: generate 2026-07-28 04:20:55 +00:00
Aiden ClineandMatt Carey 921b1c6a34 feat(mcp): upgrade client SDK to v2 (#39247)
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
2026-07-27 23:19:33 -05:00
Brendan Allan 9c8060d96d refactor(app): extract keybind settings controller (#39226) 2026-07-28 12:18:27 +08:00
Brendan Allan 87db7a7f0a fix(app): follow visual tab order (#39241) 2026-07-28 03:59:30 +00:00
opencode-agent[bot]andLuke Parker 237e694df0 fix(app): expand Windows file tree folders (#39249)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-28 03:56:40 +00:00
opencode-agent[bot] 3f9dad3fd0 chore: update nix node_modules hashes 2026-07-28 01:57:02 +00:00
opencode-agent[bot] 3cc70160de chore: generate 2026-07-28 01:43:55 +00:00
Brendan Allan b72d7b6fa2 refactor(app): separate model selector controller and view (#39084) 2026-07-28 01:42:14 +00:00
Vladimir Glafirov 029f5aa3a0 chore: bump gitlab-ai-provider to 6.12.0 (#39096) 2026-07-27 20:40:48 -05:00
Brendan Allan 284e97068c fix(app): use blue for server status attention (#39217) 2026-07-28 01:16:18 +00:00
opencode-agent[bot] 970c38257c chore: generate 2026-07-28 01:11:56 +00:00
Brendan Allan c39ad38424 fix(app): refresh global provider state (#39220) 2026-07-28 01:09:45 +00:00
opencode-agent[bot] 91508f6c88 chore: generate 2026-07-28 00:26:37 +00:00
Andrei Dziahelandadityachaudhary99 1274408b89 fix(desktop): install AppStream metainfo in Linux packages (#36872)
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
2026-07-28 08:24:20 +08:00
Luke Parker 7ae1be5bf6 fix(desktop): use channel database in local runs (#39209) 2026-07-28 10:23:43 +10:00
opencode-agent[bot]andJay 40e4d730ca fix(console): avoid Safari brand icon repaint (#39154)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 15:57:55 -04:00
OpeOginni 8dc539b4ae fix(core): correct MCP environment field in built-in skill (#39175) 2026-07-27 14:52:16 -05:00
Brendan Allan 8e8d38c3cd fix(app): prevent freeze when closing projects (#39158) 2026-07-27 18:40:30 +00:00
Jack a9bebd3653 docs(zen): add Kimi K3 (#39152) 2026-07-28 01:04:04 +08:00
opencode-agent[bot]andJay 78f5764319 fix(console): stabilize brand download icons in Safari (#39151)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 12:58:19 -04:00
usrnk1 55b0211690 feat(desktop): use bg layer 4 for v2 switch (#39085) 2026-07-27 22:23:54 +08:00
Brendan Allan 4f62295e44 fix(app): contain right panel suspense (#39080) 2026-07-27 09:56:13 +00:00
Brendan Allan e1c41941a9 refactor(app): extract tab rename adapter (#39079) 2026-07-27 09:54:24 +00:00
opencode-agent[bot] 0b4edfc64c chore: generate 2026-07-27 09:17:56 +00:00
Brendan Allan 4469cecc4e refactor(app): move prompt effects into controller (#39073) 2026-07-27 17:15:57 +08:00
Brendan Allan b06768aa8e feat(app): add project selector shortcut (#39074) 2026-07-27 17:15:35 +08:00
opencode bc2d3df05f sync release versions for v1.18.7 2026-07-27 07:32:58 +00:00
985 changed files with 115923 additions and 13459 deletions
+2
View File
@@ -10,6 +10,7 @@ kitlangton
kommander
ludvigrask
MrMushrooooom
neriousy
nexxeln
R44VC0RP
rekram1-node
@@ -19,3 +20,4 @@ Slickstef11
usrnk1
vimtor
StarpTech
vaprdev
-37
View File
@@ -1,37 +0,0 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
+1
View File
@@ -35,6 +35,7 @@ jobs:
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
+62
View File
@@ -0,0 +1,62 @@
name: models-snapshot
permissions:
contents: write
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * *"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
refresh:
if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
ref: v2
# The refresh script only needs `fetch`; skip the monorepo install.
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Setup git committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Refresh snapshot
run: bun packages/core/script/update-models-snapshot.ts
- name: Commit changes
run: |
set -euo pipefail
FILE="packages/core/src/models-dev/snapshot.txt"
if [ -z "$(git status --short -- "$FILE")" ]; then
echo "No changes to commit"
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
git add "$FILE"
git commit -m "chore(core): refresh bundled models.dev snapshot"
git pull --rebase --autostash origin v2
git push origin HEAD:v2 --no-verify
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
+2 -6
View File
@@ -219,7 +219,6 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
@@ -316,16 +315,14 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@@ -343,8 +340,7 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: build.keychain
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+52
View File
@@ -0,0 +1,52 @@
name: unlock
on:
workflow_dispatch:
inputs:
stage:
description: SST stage to unlock
required: true
type: choice
options:
- dev
- production
concurrency: deploy-${{ inputs.stage }}
permissions:
contents: read
id-token: write
jobs:
unlock:
runs-on: ubuntu-latest
environment: ${{ inputs.stage }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
aws-region: us-east-1
- run: bun sst unlock --stage=${{ inputs.stage }}
env:
GITHUB_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ inputs.stage == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: unlock@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: unlock@${{ github.sha }}
@@ -0,0 +1,63 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+1 -28
View File
@@ -157,7 +157,7 @@ Caveats:
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts`
Other tips and tricks:
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:
+236 -184
View File
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -150,8 +150,9 @@ const zenLitePrice = new stripe.Price("ZenLitePrice", {
})
const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
// Use existing Go resources in dev's checkout Stripe account.
product: $app.stage === "dev" ? "prod_U1tUscpmwtV2bG" : zenLiteProduct.id,
price: $app.stage === "dev" ? "price_1T3phhE7fOCwHSD4zS6w2NPy" : zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
@@ -221,6 +222,15 @@ const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
// Preview branches have independent databases; do not send their workspaces to shared dev.
const migrationDomain =
$app.stage === "production" ? "opencode.ai" : $app.stage === "dev" ? "dev.opencode.ai" : undefined
const consoleMigration = new sst.Linkable("ConsoleMigration", {
properties: {
consoleUrl: migrationDomain ? `https://${migrationDomain}/console` : "",
inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "",
},
})
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
@@ -235,6 +245,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@@ -255,6 +266,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
consoleMigration,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,
@@ -263,6 +275,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
@@ -270,6 +283,7 @@ new sst.cloudflare.x.SolidStart("Console", {
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
new sst.Secret("CLOUDFLARE_ACCESS_CLIENT_ID"),
...ZEN_MODELS,
...($dev
? [
@@ -288,8 +302,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
},
},
},
+13 -1
View File
@@ -181,6 +181,16 @@ const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
},
})
const r2SqlAuthToken = new sst.Secret("R2SqlAuthToken")
const r2Sql = new sst.Linkable("R2Sql", {
properties: {
accountId: "15d29c8639fd3733b1b5486a2acfd968",
bucket: `platform-${$app.stage}-lake`,
namespace: "inference",
table: "generation",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
@@ -193,7 +203,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
// Keep the legacy Athena link and IAM permissions during the first R2-backed
// release so reverting the application code remains a one-deploy rollback.
link: [database, inferenceEvent, r2Sql, r2SqlAuthToken, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
"aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
"aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
"x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
"x86_64-linux": "sha256-yKqw4YLXSzPOxPxXVWEK2W11YpwXQtFR1533Z8/s2rI=",
"aarch64-linux": "sha256-xbrn9P8be2XHOVKhH4EM2fiao2kppgJouXcoQSBRfnk=",
"aarch64-darwin": "sha256-bs0ulK/Ny3JWVcGF4CRVr2/RsLAK5bglkrshVmGrBrc=",
"x86_64-darwin": "sha256-s0j5DImmcA/tCP2kNTaYd6QugG+k8WtcDWVt7Yi2cxU="
}
}
+11 -4
View File
@@ -6,7 +6,7 @@
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/opencode src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
@@ -136,6 +137,7 @@
"electron"
],
"overrides": {
"@ai-sdk/anthropic": "3.0.111",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -143,7 +145,7 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@@ -156,6 +158,11 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch",
"@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch",
"@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch"
}
}
+16 -1
View File
@@ -11,7 +11,7 @@
- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there.
- For local UI changes, run the backend and app dev servers separately.
- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096`
- Backend (from `packages/opencode`): `bun run ./src/index.ts serve --port 4096`
- App (from `packages/app`): `bun dev -- --port 4444`
- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
@@ -19,6 +19,21 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
+16
View File
@@ -0,0 +1,16 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
@@ -197,7 +197,9 @@ export async function setupTimeline(
)
},
async waitForPart(partID: string) {
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
},
}
}
@@ -60,6 +60,39 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
test("keeps previous file search results visible while the next search loads", async ({ page }) => {
const searchPending = Promise.withResolvers<void>()
await setup(page, async ({ query }) => {
if (query === "file-0") return ["file-00.ts"]
if (query === "file-7") {
await searchPending.promise
return ["file-79.ts"]
}
return []
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill("file-0")
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
const nextSearch = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/find/file" && url.searchParams.get("query") === "file-7"
})
await filter.fill("file-7")
await nextSearch
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
searchPending.resolve()
await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible()
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden()
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
@@ -74,7 +107,10 @@ async function readProbe(page: Page) {
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
async function setup(
page: Page,
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -119,6 +155,7 @@ async function setup(page: Page) {
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles,
pageMessages: () => ({ items: [] }),
})
@@ -1,41 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -0,0 +1,132 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-expand",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
{
name: "app.ts",
path: "frontend\\app.ts",
absolute: `${directory}/frontend/app.ts`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
return [
{
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
ignored: false,
},
{
name: "README.md",
path: "README.md",
absolute: `${directory}/README.md`,
type: "file" as const,
ignored: false,
},
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
})
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
@@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start)
await expectAppVisible(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await start.dragTo(end)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
const textbox = review.getByRole("textbox")
await expect(textbox).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
@@ -144,15 +148,22 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
const changes = page.getByRole("tab", { name: "Changes" })
const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
const file = review.locator('[data-file="src/review.ts"]')
await expectAppVisible(file)
const trigger = file.getByRole("button", { expanded: false })
await expect(trigger).toHaveCount(1)
await trigger.click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
}
@@ -0,0 +1,141 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
protocol: "v1",
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => {
if (route.request().method() !== "PATCH") return route.fallback()
const id = new URL(route.request().url()).pathname.split("/").at(-1)
const session = sessions.find((item) => item.id === id)
const payload: unknown = route.request().postDataJSON()
if (
!session ||
!payload ||
typeof payload !== "object" ||
!("title" in payload) ||
typeof payload.title !== "string"
)
throw new Error("Invalid rename request")
session.title = payload.title
await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
for (const commit of ["Enter", "blur", "click outside"]) {
test(`saves the session heading on ${commit}`, async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
})
}
test("cancels the session heading with Escape", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Discard this title")
await input.press("Escape")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("keeps the draft when saving the session heading fails", async ({ page }) => {
await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => {
if (route.request().method() !== "PATCH") return route.fallback()
return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } })
})
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Retry this title")
await input.press("Tab")
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(input).toBeEnabled()
await expect(input).toHaveValue("Retry this title")
await expect(
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
).toBeVisible()
})
test("does not save an empty session heading", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill(" ")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("renames and closes the session tab from its context menu", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
await renamed.click({ button: "right" })
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
await expect(renamed).toBeHidden()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
})
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundet" },
{ locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
@@ -128,7 +128,7 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
test("renders inline comments and historical diff summary overflow", async ({ page }) => {
const user = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@@ -152,14 +152,10 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, {
messages: [user, assistantMessage(), nextUser, nextAssistant],
settings: { newLayoutDesigns: false },
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.getByText(/show all/i)).toBeVisible()
@@ -1,9 +1,8 @@
import { expect, test } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID)
expect(await timeline.transport.connections()).toHaveLength(1)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
})
@@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
})
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const sentinelID = "prt_transport_heartbeat_sentinel"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
})
const before = await page.locator("[data-timeline-row]").allTextContents()
await timeline.waitForPart("prt_transport_steady")
const before = await stableTimelineRows(page)
await timeline.transport.heartbeat()
await timeline.settle()
await timeline.transport.writeRaw(": heartbeat\n\n")
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
await timeline.waitForPart(sentinelID)
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
expect(await timeline.transport.connections()).toHaveLength(1)
await expect
.poll(async () => {
const rows = await timelineRows(page)
return rows.filter((row) => before.some((item) => item.key === row.key))
})
.toEqual(before)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection()
await timeline.transport.close()
@@ -77,20 +84,21 @@ test("reconnects after a clean close", async ({ page }) => {
})
test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(status("busy"))
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const timeline = await setupTimeline(page, { protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@@ -112,5 +120,35 @@ test("passes through non-event fetches", async ({ page }) => {
})
expect(health).toEqual({ healthy: true })
expect(await timeline.transport.connections()).toHaveLength(1)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
async function stableTimelineRows(page: Page) {
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
let stable = 0
await expect
.poll(
async () => {
const next = await timelineRows(page)
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
previous = next
return stable
},
{ intervals: [50, 50, 100] },
)
.toBeGreaterThanOrEqual(2)
return previous!
}
function timelineRows(page: Page) {
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
key: element.getAttribute("data-timeline-key"),
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: element.textContent,
})),
)
}
@@ -39,7 +39,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
@@ -5,6 +5,8 @@ import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
@@ -40,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("keyboard navigation follows the visible tab order", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, unresolved, sessionC }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: unresolved },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
await page.keyboard.press("Control+Alt+ArrowRight")
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) {
return {
id,
@@ -53,10 +83,12 @@ function session(id: string, title: string) {
}
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB]
const sessions = [sessionA, sessionB, sessionC]
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
+1 -1
View File
@@ -26,7 +26,7 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
}
+10 -5
View File
@@ -247,18 +247,23 @@ export async function installSseTransport<T>(
return {
server,
async waitForConnection(input = {}) {
await page.waitForFunction(
const connection = await page.waitForFunction(
(after) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.some((connection) => connection.id > after)
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
},
input.after ?? 0,
{ timeout: input.timeout },
)
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
(connection) => connection.id > (input.after ?? 0),
)!
let result: SseConnectionRecord | undefined
try {
result = await connection.jsonValue()
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
+5 -4
View File
@@ -1,11 +1,12 @@
{
"name": "@opencode-ai/app",
"version": "1.18.6",
"version": "1.18.32",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
@@ -19,9 +20,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
@@ -31,7 +32,7 @@
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@happy-dom/global-registrator": "20.12.0",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
+16 -8
View File
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
@@ -234,7 +233,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
)
}
function LayoutCompatibility(props: ParentProps) {
@@ -327,7 +332,7 @@ function DesktopCommands() {
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
@@ -385,7 +390,12 @@ function DraftProviders(props: ParentProps) {
)
}
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
return (
<MetaProvider>
<Font />
@@ -394,7 +404,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
@@ -405,9 +415,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
+64 -27
View File
@@ -60,35 +60,44 @@ function Cell(props: {
label: string
tip: string
value: string
wide?: boolean
span?: 2 | 3
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
"col-span-2": props.span === 2 && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
"flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</div>
)
@@ -108,37 +117,50 @@ function Cell(props: {
)
}
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
function ToggleCell(props: {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-label={`${props.label}: ${props.value}`}
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
<span
classList={{
flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
<Tooltip value={props.tip} placement="top">
{content()}
</Tooltip>
)
@@ -457,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
"grid-cols-4": !props.inline,
grid: !props.inline,
}}
>
@@ -539,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
wide={!platform.setForceFocus}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
)}
</div>
</aside>
@@ -28,17 +28,16 @@ import {
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
@@ -386,9 +385,9 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.())
@@ -512,7 +511,12 @@ function ProviderConnection(props: {
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
hint:
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
}
}
@@ -558,6 +562,11 @@ function ProviderConnection(props: {
})
.then((x) => {
if (!alive.value) return
if (props.provider === "opencode" && platform.platform === "desktop") {
const url = new URL(x.data.url)
url.searchParams.set("client_id", "opencode-desktop")
x.data.url = url.href
}
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
@@ -707,9 +716,8 @@ function ProviderConnection(props: {
})
async function complete() {
const value = directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
@@ -840,12 +848,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -890,9 +898,9 @@ function ProviderConnection(props: {
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -971,9 +979,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1010,7 +1018,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1081,7 +1091,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
</ExternalLink>
{language.t("provider.custom.description.suffix")}
</p>
@@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
@@ -57,9 +59,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const missingHome = createMemo(() => !sync.data.path.home)
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() => (missingHome() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
@@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
@@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
return uniqueRows([...recentProjects(), ...directoryRows])
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
@@ -1,15 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import {
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
@@ -122,14 +113,13 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Trigger as={props.trigger} />
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
return (
<ModelSelectorPopoverV2View
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
}}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
)
const models = createMemo(() => {
const search = store.search.trim()
const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
})
const groups = createMemo(() => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
})
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
return {
models: (search: string) => {
const query = search.trim()
const filtered = query
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
},
groups: (models: ModelItem[]) => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
},
current: () => {
const value = model.current()
return value ? modelKey(value) : undefined
},
select: (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
input.onSelect()
},
}
}
function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => {
const selected = current()
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
}
const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => {
if (open) {
restoreTrigger = true
dismiss.allowTriggerRestore()
setStore({ open: true, active: initialActive() })
setTimeout(() =>
requestAnimationFrame(() => {
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
}
setStore({ open: false, search: "", active: "" })
}
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => select(item))
dismiss.afterClose(() => props.select(item))
}
const manage = () => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
dismiss.afterClose(props.onManage)
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
const first = props.models(value)[0]
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Trigger as={props.trigger} />
<MenuV2.Portal>
<MenuV2.Content
ref={(el: HTMLDivElement) => (contentRef = el)}
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
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">
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => props.onClose?.())
dismiss.afterClose(props.onClose)
return
}
if (event.altKey || event.metaKey) return
@@ -442,10 +450,10 @@ export function ModelSelectorPopoverV2(props: {
<For each={groups()}>
{(group) => (
<MenuV2.Group>
<MenuV2.GroupLabel class="gap-2 px-3">
<MenuV2.GroupLabel class="sticky top-0 z-10 gap-2 bg-v2-background-bg-layer-01 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={current()}>
<MenuV2.RadioGroup value={props.current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
@@ -145,7 +145,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
defaultValue={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
@@ -155,7 +155,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
defaultValue={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
@@ -164,7 +164,7 @@ function ServerForm(props: ServerFormProps) {
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
defaultValue={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
@@ -1,4 +1,5 @@
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -14,10 +15,11 @@ export type DialogGoUpsellProps = {
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const runAction = () => {
if (props.link) platform.openLink(props.link)
if (props.link) platform.openExternal(props.link)
props.onClose?.()
dialog.close()
}
@@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
Don't show again
{language.t("dialog.usageExceeded.dontShowAgain")}
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}
@@ -139,6 +139,7 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@@ -152,6 +153,67 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
@@ -379,7 +379,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -0,0 +1,21 @@
import { ComponentProps, splitProps } from "solid-js"
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function ExternalLink(props: ExternalLinkProps) {
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
target={local.target ?? "_blank"}
rel={local.rel ?? "noopener noreferrer"}
{...rest}
>
{local.children}
</a>
)
}
+9 -7
View File
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) {
function rowPaddingStart(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP
}
function guideLineLeft(level: number) {
return rowPaddingLeft(level, "directory") + 8
function guideLineStart(level: number) {
return rowPaddingStart(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
@@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">{local.node.name}</bdi>
</span>
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
@@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
</For>
)
}
@@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
+2 -2
View File
@@ -146,13 +146,13 @@ const FileTreeNode = (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...local.classList,
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
+27 -32
View File
@@ -1,9 +1,11 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { isRTL } from "@kobalte/core/i18n"
import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useLanguage } from "@/context/language"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
@@ -15,20 +17,22 @@ const showPopover = () => true
export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const language = useLanguage()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const rtl = () => isRTL(language.intl())
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label={language.t("help.tabs.toast.ariaLabel")}
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
aria-label={language.t("help.tabs.toast.dismiss")}
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
>
<svg
@@ -44,7 +48,7 @@ export function TabsInfoPopup() {
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
onClick={() => {
settings.general.dismissTabsToast()
setDrawerOpen(true)
@@ -62,10 +66,10 @@ export function TabsInfoPopup() {
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
{language.t("help.tabs.title")}
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
{language.t("help.tabs.description")}
</p>
</div>
</button>
@@ -75,7 +79,10 @@ export function TabsInfoPopup() {
style={
windows()
? {
inset: "0 0 0 auto",
top: "0",
bottom: "0",
"inset-inline-end": "0",
"inset-inline-start": "auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
@@ -89,9 +96,9 @@ export function TabsInfoPopup() {
type="button"
size="small"
variant="neutral"
aria-label="Close"
aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
class="absolute top-[10px] start-[-36px]"
/>
</Show>
<div
@@ -102,7 +109,7 @@ export function TabsInfoPopup() {
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
{language.t("help.tabs.date")}
</p>
<Show when={!windows()}>
<DrawerClose
@@ -110,36 +117,24 @@ export function TabsInfoPopup() {
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
{language.t("help.tabs.title")}
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<p>{language.t("help.tabs.introduction")}</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<p>{language.t("help.tabs.sessions")}</p>
<p>{language.t("help.tabs.organize")}</p>
<p>{language.t("help.tabs.home")}</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
<p>{language.t("help.tabs.persistence")}</p>
<p>{language.t("help.tabs.worktrees")}</p>
</div>
</div>
</DrawerContent>
-26
View File
@@ -1,26 +0,0 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</a>
)
}
+81 -86
View File
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
@@ -51,15 +49,13 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const command = useCommand()
const language = useLanguage()
useCommands(props)
useEditHandler(props)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
@@ -82,70 +78,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
@@ -205,7 +137,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const designPlaceholder = () =>
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -412,7 +347,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
@@ -436,6 +371,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
@@ -444,6 +380,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
},
view: {
placeholder: designPlaceholder,
@@ -472,6 +409,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: controller.state.mode !== "normal",
onSelect: () => controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: controller.state.mode === "shell",
onSelect: () => controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: controller.state.mode === "normal",
onSelect: () => controller.dispatch({ type: "mode.normal" }),
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController
}
@@ -535,20 +528,22 @@ function PromptInputV2ModelControl(props: {
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
"data-control-type": "popover",
}}
trigger={(triggerProps) => (
<ButtonV2
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="prompt-model"
data-control-type="popover"
>
{content()}
</ButtonV2>
)}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
/>
</Show>
</TooltipV2>
</Show>
+27 -24
View File
@@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
@@ -1489,10 +1489,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
fileLabel={language.t("ui.common.file")}
newLayoutDesigns={false}
/>
<div
@@ -1723,29 +1724,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<ModelSelectorPopover
model={props.controls.model.selection}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
trigger={(triggerProps) => (
<Button
{...triggerProps}
variant="ghost"
size="normal"
style={control()}
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
data-action="prompt-model"
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
)}
onClose={restoreFocus}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
/>
</TooltipKeybind>
</Show>
</div>
@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
draftStore?: DraftStore
}
export type PromptAttachmentsInput = {
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false
}
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({
...input,
draftStore: platform.draftStore,
capture: input.prompt.capture,
warn: () => {
showToast({
@@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: ImageAttachmentPart[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
@@ -1,7 +1,13 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
}
}
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
@@ -16,6 +16,7 @@ type PromptImageAttachmentsProps = {
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
fileLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
@@ -94,13 +95,13 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
}
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
</AttachmentCardV2>
</Show>
}
>
<img
src={attachment.dataUrl}
src={attachment.blob.url}
alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { promptPlaceholder } from "./placeholder"
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
@@ -46,3 +46,18 @@ describe("promptPlaceholder", () => {
expect(value).toBe("prompt.placeholder.simple")
})
})
describe("promptDesignPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => {
if (key !== "ui.promptInput.placeholder.normal") return key
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
}
test("composes the design placeholder from localized fragments", () => {
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
})
test("preserves the shell placeholder", () => {
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
})
})
@@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
return input.t("prompt.placeholder.normal", { example: input.example })
}
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
export function promptDesignPlaceholder(
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
@@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
@@ -95,10 +96,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
return true
} catch (err) {
@@ -108,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
const messageID = input.messageID ?? Identifier.ascending("message")
const encodedImages = await Promise.all(
images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images,
images: encodedImages,
text,
sessionID: input.draft.sessionID,
messageID,
@@ -516,10 +525,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = {
name?: string
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
dismiss.afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
dismiss.afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
dismiss.preventTriggerRestore()
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
onOpenChange={(open) => {
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
>
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal>
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
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">
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -31,7 +31,10 @@ export function OpenInAppV2(props: { directory: () => string }) {
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Show
when={state.opening()}
fallback={<AppIcon id={state.current().icon} style={{ width: "20px", height: "20px" }} />}
>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@@ -120,7 +123,8 @@ export function SessionContextTab() {
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
@@ -220,6 +224,31 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
sessionID,
client: sdk().client,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
@@ -328,7 +357,18 @@ export function SessionContextTab() {
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<div class="flex items-center justify-between">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => (
@@ -289,9 +289,9 @@ export function SessionHeader() {
return (
<>
<Show when={search() && centerMount()}>
<Show when={search() && centerMount()} keyed>
{(mount) => (
<Portal mount={mount()}>
<Portal mount={mount}>
<Button
type="button"
variant="ghost"
@@ -308,10 +308,10 @@ export function SessionHeader() {
</span>
</div>
<Show when={hotkey()}>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
{keybind}
</Keybind>
)}
</Show>
@@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal>
)}
</Show>
<Show when={rightMount()}>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount()}>
<Portal mount={mount}>
<Show
when={isV2}
fallback={
@@ -1,16 +0,0 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -29,7 +29,7 @@ import {
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { ExternalLink } from "./external-link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
</>
}
>
+289 -61
View File
@@ -30,6 +30,8 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
type LanguageContext = ReturnType<typeof useLanguage>
type SettingsContext = ReturnType<typeof useSettings>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" })
@@ -262,7 +264,274 @@ function useKeyCapture(input: {
})
}
export function createKeybindSettingsController(
input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
settings: {
current: { keybinds: unknown }
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
}
target?: Document
notify?: (toast: { title: string; description: string }) => void
},
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
) {
const [store, setStore] = createStore({ active: null as string | null })
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
const list = createMemo(() => {
language.locale()
return listFor(input.command, overrides(), language.t("command.palette"))
})
const grouped = createMemo(() => groupedFor(list()))
const title = (id: string) => list().get(id)?.title ?? ""
const effective = (id: string) => {
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
const custom = input.settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = input.command.options.find((item) => item.id === id)
if (live?.keybind) return live.keybind
return input.command.catalog.find((item) => item.id === id)?.keybind
}
const used = createMemo(() => {
const value = new Map<string, { id: string; title: string }[]>()
for (const id of list().keys()) {
for (const signature of signatures(effective(id))) {
const items = value.get(signature)
if (items) {
items.push({ id, title: title(id) })
continue
}
value.set(signature, [{ id, title: title(id) }])
}
}
return value
})
const stop = () => {
if (!store.active) return
setStore("active", null)
input.command.keybinds(true)
}
const toggle = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
input.command.keybinds(false)
}
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.settings.keybinds.set(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const signature of signatures(next)) {
for (const item of used().get(signature) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
notify({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.settings.keybinds.set(id, next)
stop()
}
const target = input.target ?? (typeof document === "object" ? document : undefined)
if (target) makeEventListener(target, "keydown", handle, { capture: true })
onCleanup(() => {
if (store.active) input.command.keybinds(true)
})
return {
catalog: {
groups: GROUPS,
filtered: (query: string) =>
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
title,
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
},
capture: {
active: () => store.active,
toggle,
},
settings: {
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
reset: () => {
stop()
input.settings.keybinds.resetAll()
notify({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
},
},
}
}
function SettingsKeybindsV2() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
command,
settings,
})
return (
<SettingsKeybindsV2View
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
}
function SettingsKeybindsV2View(props: {
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: () => string | null
onCapture: (id: string) => void
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
<SettingsListV2>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsListV2>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-v2-shortcuts-status">
<span>{language.t("settings.shortcuts.search.empty")}</span>
<span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
</div>
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
@@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
)
return (
<Show
when={props.v2}
fallback={
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</Button>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
class="flex-1"
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
<div class="settings-v2-tab-body">{groups}</div>
</>
</Show>
</div>
{groups}
</div>
)
}
@@ -0,0 +1,70 @@
import { onCleanup } from "solid-js"
export type ShellOption = {
path: string
name: string
acceptable: boolean
}
export type ShellSelectOption = {
id: string
value: string
name: string
terminalOnly: boolean
}
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
const counts = input.shells.reduce((result, shell) => {
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
return result
}, new Map<string, number>())
const options: ShellSelectOption[] = [
{ id: "auto", value: "", name: "", terminalOnly: false },
...input.shells.map((shell) => {
const ambiguous = (counts.get(shell.name) ?? 0) > 1
const name = ambiguous ? shell.path : shell.name
return {
id: shell.path,
value: ambiguous ? shell.path : shell.name,
name,
terminalOnly: !shell.acceptable,
}
}),
]
if (input.current && !options.some((option) => option.value === input.current)) {
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
}
return options
}
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
let cleanup: (() => void) | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let run = 0
const stop = () => {
run += 1
cleanup?.()
clearTimeout(timeout)
cleanup = undefined
timeout = undefined
}
const play = (id: string | undefined) => {
stop()
if (!id) return
const current = ++run
timeout = setTimeout(() => {
timeout = undefined
void player(id).then((next) => {
if (run === current) {
cleanup = next
return
}
next?.()
})
}, 100)
}
onCleanup(stop)
return { play, stop }
}
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
describe("settings v2 controllers", () => {
test("normalizes shell names and preserves an unavailable configured shell", () => {
expect(
createShellOptions({
shells: [
{ path: "/bin/bash", name: "bash", acceptable: true },
{ path: "/opt/bash", name: "bash", acceptable: false },
{ path: "/bin/zsh", name: "zsh", acceptable: true },
],
current: "fish",
}),
).toEqual([
{ id: "auto", value: "", name: "", terminalOnly: false },
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
])
})
test("debounces previews and stops owned audio on disposal", async () => {
vi.useFakeTimers()
try {
const played: string[] = []
const stopped: string[] = []
const owned = createRoot((dispose) => ({
dispose,
preview: createSoundPreviewController(async (id) => {
played.push(id ?? "")
return () => stopped.push(id ?? "")
}),
}))
owned.preview.play("first")
vi.advanceTimersByTime(99)
expect(played).toEqual([])
owned.preview.play("second")
vi.advanceTimersByTime(100)
await Promise.resolve()
expect(played).toEqual(["second"])
owned.dispose()
expect(stopped).toEqual(["second"])
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,173 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
const directory = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController() {
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverSync().data.config.shell ?? "")
return {
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
void serverSync().updateConfig({ shell: value })
},
}
}
export function createAppearanceSettingsController() {
const settings = useSettings()
const theme = useTheme()
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
onMount(() => void theme.loadThemes())
return {
scheme: {
current: theme.colorScheme,
select: (value: ColorScheme) => theme.setColorScheme(value),
},
theme: {
options: themes,
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
},
fonts: {
ui: createMemo(() => ({
value: sansInput(settings.appearance.uiFont()),
family: sansFontFamily(settings.appearance.uiFont()),
placeholder: sansDefault,
})),
code: createMemo(() => ({
value: monoInput(settings.appearance.font()),
family: monoFontFamily(settings.appearance.font()),
placeholder: monoDefault,
})),
terminal: createMemo(() => ({
value: terminalInput(settings.appearance.terminalFont()),
family: terminalFontFamily(settings.appearance.terminalFont()),
placeholder: terminalDefault,
})),
setUI: (value: string) => settings.appearance.setUIFont(value),
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
}
}
const noneSound = { id: "none", label: "sound.option.none" } as const
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
export type SoundSelectOption = (typeof soundOptions)[number]
export function createSoundSettingsController() {
const settings = useSettings()
const preview = createSoundPreviewController(playSoundById)
const channel = (
enabled: Accessor<boolean>,
current: Accessor<string>,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
current: createMemo(() =>
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
),
highlight: (option: SoundSelectOption | undefined) => {
if (!option) return
preview.play(option.id === "none" ? undefined : option.id)
},
select: (option: SoundSelectOption | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
preview.stop()
return
}
setEnabled(true)
set(option.id)
preview.play(option.id)
},
})
return {
agent: channel(
settings.sounds.agentEnabled,
settings.sounds.agent,
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
),
permissions: channel(
settings.sounds.permissionsEnabled,
settings.sounds.permissions,
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
),
errors: channel(
settings.sounds.errorsEnabled,
settings.sounds.errors,
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
),
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
@@ -1,182 +1,297 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { Component, Show, createMemo, createResource } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
/>
</div>
</SettingsRowV2>
)
}
type ThemeOption = {
id: string
name: string
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
shells: props.controller.shells(),
current: props.controller.current(),
}),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => {
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
if (!option.terminalOnly) return option.name
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
}}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRowV2>
)
}
type ShellOption = {
path: string
name: string
acceptable: boolean
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && props.controller.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={props.controller.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={props.controller.fonts} />
<FontSetting kind="code" fonts={props.controller.fonts} />
<FontSetting kind="terminal" fonts={props.controller.fonts} />
</SettingsListV2>
</div>
)
}
type ShellSelectOption = {
id: string
value: string
label: string
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsListV2>
</div>
)
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={options()}
placement="bottom-end"
gutter={6}
current={options().find((option) => option.value === language.locale())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
)
}
export const SettingsGeneralV2: Component<{
sessionID?: string
}> = (props) => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const dir = createMemo(() => {
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
if (!value || !props.sessionID) return false
return permission.isAutoAccepting(props.sessionID, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value || !props.sessionID) return
if (checked) {
permission.enableAutoAccept(props.sessionID, value)
return
}
permission.disableAutoAccept(props.sessionID, value)
}
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => desktop() && "getPinchZoomEnabled" in platform,
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
@@ -184,52 +299,6 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
@@ -251,59 +320,18 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
/>
)
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<LanguageSetting />
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<PermissionScopeSetting controller={permissionScope} />
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<ShellSetting controller={shell} />
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -414,124 +442,6 @@ export const SettingsGeneralV2: Component<{
</div>
)
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -576,68 +486,6 @@ export const SettingsGeneralV2: Component<{
</div>
)
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -659,7 +507,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
{language.t(updater.action().label)}
</ButtonV2>
</SettingsRowV2>
@@ -704,11 +552,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection />
<AppearanceSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-v2-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<h3 class="settings-v2-models-group-header">
<button
type="button"
class="settings-v2-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-v2-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</span>
<span class="settings-v2-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
)
}}
</For>
</Show>
</Show>
@@ -287,7 +287,7 @@
}
.settings-v2-provider-env-hint {
padding-right: 12px;
padding-inline-end: 12px;
font-size: 13px;
font-weight: 440;
line-height: 1;
@@ -311,7 +311,7 @@
line-height: 1;
color: var(--v2-text-text-accent);
cursor: pointer;
text-align: left;
text-align: start;
}
.settings-v2-providers-view-all:hover {
@@ -361,26 +361,76 @@
}
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-right: 28px;
padding-inline-end: 28px;
}
.settings-v2-tab-search-clear {
position: absolute;
top: 50%;
right: 6px;
inset-inline-end: 6px;
z-index: 1;
transform: translateY(-50%);
}
.settings-v2-models {
gap: 24px;
gap: 12px;
}
.settings-v2-models .settings-v2-section {
gap: 12px;
}
.settings-v2-models .settings-v2-section[data-expanded] {
padding-bottom: 12px;
}
.settings-v2-models-group-header {
display: flex;
align-items: center;
height: 28px;
}
.settings-v2-models-group-trigger {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
}
.settings-v2-models-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.settings-v2-models-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.settings-v2-models-group-trigger:disabled {
cursor: default;
}
.settings-v2-models-group-chevron {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
}
.settings-v2-models-group-label {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.settings-v2-models .settings-v2-section-title {
@@ -394,6 +444,10 @@
color: var(--v2-icon-icon-base);
}
.settings-v2-models [data-component="settings-v2-list"] {
padding-inline: 16px;
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0;
}
@@ -1,11 +1,21 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,6 +1,10 @@
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
@@ -11,9 +15,15 @@ export function hasNonBlockingServiceIssue(input: {
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
export function serverStatusDotClass(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
+19 -1
View File
@@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -22,6 +26,11 @@ export function StatusPopover() {
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -49,6 +58,7 @@ export function StatusPopover() {
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
})}`}
/>
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
shown: shown(),
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
shown: shown(),
ready: serverHealth() !== undefined,
serverHealth: serverHealth(),
attention: false,
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
@@ -135,6 +152,7 @@ type StatusPopoverState = {
shown: boolean
ready: boolean
serverHealth: boolean | undefined
attention: boolean
issue: boolean
label: string
onOpenChange: (value: boolean) => void
+8 -4
View File
@@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault()
event.stopImmediatePropagation()
platform.openLink(text)
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
platform.openLocalFile(text)
return
}
platform.openExternal(text)
}
onMount(() => {
@@ -569,9 +573,8 @@ export const Terminal = (props: TerminalProps) => {
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status }))
}
// return sdk()
// .api.pty.connectToken({
@@ -738,6 +741,7 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
dir="ltr"
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
if (typeof ref === "function") ref(element)
}
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
return !dragging && !editing && !pending
}
@@ -1,7 +1,7 @@
[data-titlebar-tab] [data-slot="tab-close"] {
position: absolute;
top: 4px;
right: 4px;
inset-inline-end: 4px;
display: flex;
height: 20px;
width: 20px;
@@ -9,58 +9,108 @@
justify-content: center;
}
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
gap: 6px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
}
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: "";
position: absolute;
top: 8px;
left: -6.75px;
inset-inline-start: -3.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--v2-background-bg-layer-02);
background: var(--tab-separator);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
padding-right: 0;
padding-inline-end: 0;
}
[data-titlebar-tab] [data-slot="tab-title"] {
+137 -128
View File
@@ -1,14 +1,16 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -23,8 +25,7 @@ export function TabNavItem(props: {
server: ServerConnection.Key
session: () => Session | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onRename: (title: string) => Promise<void>
onClose: () => void
onNavigate: () => void
active?: boolean
@@ -35,12 +36,13 @@ export function TabNavItem(props: {
hidden?: boolean
}) {
const language = useLanguage()
const [menu, setMenu] = createStore({ open: false, rename: false })
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined
const rename = createMutation(() => ({ mutationFn: props.onRename }))
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -78,7 +80,7 @@ export function TabNavItem(props: {
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const previewBlocked = () => !!props.dragging || editing() || menu.open || !!props.pressed || !props.session()
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
@@ -116,40 +118,20 @@ export function TabNavItem(props: {
selection?.addRange(range)
}
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
if (committing || !editing()) return
committing = true
if (rename.isPending || !editing()) return
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false)
if (!save || !next || next === original) {
committing = false
return
}
try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
await rename.mutateAsync(next)
}
createEffect(() => {
@@ -160,10 +142,10 @@ export function TabNavItem(props: {
titleEl.textContent = value
})
const openRename = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), committing)) return
const openRename = (event?: MouseEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
@@ -193,7 +175,7 @@ export function TabNavItem(props: {
onCleanup(cleanup)
})
const tab = (
const tab = () => (
<div
ref={(el) => {
tabRoot = el
@@ -203,11 +185,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -218,90 +200,93 @@ export function TabNavItem(props: {
closeTab(event)
}}
>
<Show when={title() !== undefined}>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
<MenuV2.Context.Trigger
as="a"
disabled={editing() || props.dragging}
aria-haspopup="menu"
aria-expanded={menu.open}
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session.directory}
sessionId={session.id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
</Show>
/>
</MenuV2.Context.Trigger>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
@@ -312,26 +297,49 @@ export function TabNavItem(props: {
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</div>
</div>
)
return (
<TabPreviewPopover
trigger={tab}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
setPopoverOpen(value)
<MenuV2.Context
onOpenChange={(open) => {
setMenu("open", open)
if (open) setPopoverOpen(false)
}}
data={{
projectName: projectName(),
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
/>
>
<TabPreviewPopover
trigger={tab()}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
setPopoverOpen(value)
}}
data={{
projectName: projectName(),
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
/>
<MenuV2.Context.Portal>
<MenuV2.Context.Content
onCloseAutoFocus={(event) => {
if (!menu.rename) return
event.preventDefault()
setMenu("rename", false)
openRename()
}}
>
<MenuV2.Item disabled={!props.session() || rename.isPending} onSelect={() => setMenu("rename", true)}>
{language.t("common.rename")}
</MenuV2.Item>
<MenuV2.Item onSelect={props.onClose}>{language.t("common.closeTab")}</MenuV2.Item>
</MenuV2.Context.Content>
</MenuV2.Context.Portal>
</MenuV2.Context>
)
}
@@ -347,6 +355,7 @@ export function DraftTabItem(props: {
pressed?: boolean
hidden?: boolean
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -359,8 +368,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -406,7 +415,7 @@ export function DraftTabItem(props: {
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
@@ -421,7 +430,7 @@ export function DraftTabItem(props: {
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
aria-label={language.t("common.closeTab")}
/>
</div>
</div>
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
describe("adjacentTabKey", () => {
test("follows the visible left-to-right order", () => {
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
})
test("skips tabs omitted from the visible order", () => {
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
})
})
test("merges reordered visible tabs around hidden tabs", () => {
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
"c",
"hidden",
"a",
"b",
])
})
@@ -0,0 +1,12 @@
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
if (!current || order.length === 0) return
const index = order.indexOf(current)
if (index === -1) return
return order[(index + offset + order.length) % order.length]
}
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
const visible = new Set(current)
const reordered = next.values()
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
}
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -15,7 +16,10 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@opencode-ai/sdk/v2"
function SessionTabSlot(props: {
tab: SessionTab
@@ -23,12 +27,12 @@ function SessionTabSlot(props: {
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
session: () => Session | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({
get id() {
return props.id
@@ -38,6 +42,47 @@ function SessionTabSlot(props: {
},
})
let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={props.session}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
@@ -50,8 +95,30 @@ function SessionTabSlot(props: {
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => {
const ctx = props.serverCtx()
const value = session()
@@ -83,39 +150,20 @@ function SessionTabSlot(props: {
})
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
<Show when={visible()}>
<SessionTabSlot
tab={props.tab}
id={props.id}
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onRename={rename}
onNavigate={props.onNavigate}
onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
</Show>
)
}
@@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: {
}) {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
const tabIds = () => props.tabs.map(tabKey)
command.register("titlebar-tab-cycle", () => [
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(-1),
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(1),
},
])
function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
}
function refreshOverflow() {
if (!scrollRef) return
@@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: {
createEffect(() => {
props.tabs.length
tabIds()
visibleTabIds()
refreshOverflow()
})
@@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: {
props.onNavigate(tab, tabEl ?? undefined)
}}
onDragEnd={(event) => {
const current = tabIds()
const current = visibleTabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(arrayMove(current, source.initialIndex, source.index))
props.onReorder(
mergeVisibleTabOrder(
props.tabs.map(tabKey),
current,
arrayMove(current, source.initialIndex, source.index),
),
)
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
{(tab, index) => {
{(tab) => {
const id = tabKey(tab)
let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref))
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: {
if (tab.type === "session") {
return (
<SessionTabSlot
<SessionTabEntry
tab={tab}
id={id}
index={index}
index={visibleIndex}
active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
@@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot
tab={tab}
id={id}
index={index}
index={visibleIndex}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
@@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => {
const number = index() + 1
if (number > 9) return []
if (number < 1 || number > 9) return []
return [
{
id: `tab.${number}`,
+41 -62
View File
@@ -1,4 +1,15 @@
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
}
export function useTitlebarRightMount() {
const language = useLanguage()
const [mount, setMount] = createSignal<HTMLElement | null>(null)
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount
}
@@ -113,9 +127,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return {
visible: version !== undefined || installing,
installing,
label: "Update",
label: language.t("titlebar.update"),
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
onInstall: () => props.update?.install(),
}
})
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"align-self": windows() ? "flex-start" : undefined,
// Native Windows caption controls remain on the physical right in both writing directions.
"margin-right": windows() ? "auto" : undefined,
}}
data-tauri-drag-region
>
@@ -338,40 +353,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
].filter((v) => v !== undefined)
})
@@ -429,27 +410,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
@@ -551,7 +530,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
@@ -649,7 +628,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
aria-label={props.state.ariaLabel}
>
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
Update
{props.state.label}
</span>
<span class="flex size-5 shrink-0 items-center justify-center">
<Show
+1 -1
View File
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
"group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
@@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
@@ -15,6 +16,7 @@ export function WindowsAppMenu(props: {
variant?: "legacy" | "v2"
}) {
let lastFocused: HTMLElement | undefined
const language = useLanguage()
const rememberFocus = () => {
const active = document.activeElement
@@ -43,7 +45,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action)
return
}
if (entry.href) props.platform.openLink(entry.href)
if (entry.href) props.platform.openExternal(entry.href)
}
return (
@@ -58,7 +60,7 @@ export function WindowsAppMenu(props: {
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label="OpenCode menu"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
@@ -69,7 +71,7 @@ export function WindowsAppMenu(props: {
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
@@ -79,7 +81,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}>
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
+2 -2
View File
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
),
).sort()
export function filePickerFilters(ext?: string[]) {
export function filePickerFilters(name: string, ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
return [{ name, extensions: ext }]
}
@@ -21,6 +21,29 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
})
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend")
})
test("normalizes separators for Windows roots written with forward slashes", () => {
const path = createPathHelpers(() => "C:/repo")
expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src")
})
test("normalizes separators for Windows UNC roots", () => {
const path = createPathHelpers(() => "\\\\server\\share")
expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend")
})
test("preserves backslashes in POSIX directory names", () => {
const path = createPathHelpers(() => "/repo")
expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\")
expect(path.normalizeDir("literal\\name/")).toBe("literal\\name")
})
test("keeps query/hash stripping behavior stable", () => {
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+6 -1
View File
@@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) {
return normalize(tabValue)
}
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
const normalizeDir = (input: string) => {
const path = normalize(input)
const root = scope()
const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "")
}
return {
normalize,
@@ -8,6 +8,7 @@ import {
bootstrapDirectory,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
@@ -76,6 +77,7 @@ function directoryState() {
describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const legacyConfigReads: string[] = []
const mcpReads: string[] = []
const [store, setStore] = directoryState()
@@ -91,7 +93,12 @@ describe("bootstrapDirectory", () => {
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
config: {
get: async () => {
legacyConfigReads.push("directory")
return { data: {} }
},
},
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
@@ -134,8 +141,88 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(legacyConfigReads).toEqual(["directory"])
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
test("skips legacy config while refreshing a v2 directory", async () => {
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
config: {
get: async () => {
throw new Error("legacy directory config should not be called")
},
},
} as unknown as OpencodeClient,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v2"),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
})
})
describe("config queries", () => {
test("skips legacy global config for v2 servers", async () => {
const sdk = {
global: {
config: {
get: async () => {
throw new Error("legacy global config should not be called")
},
},
},
} as unknown as OpencodeClient
const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
)
expect(result).toEqual({})
})
test("loads legacy global config for v1 servers", async () => {
const calls: string[] = []
const config = { shell: "zsh" } satisfies Config
const sdk = {
global: {
config: {
get: async () => {
calls.push("global")
return { data: config }
},
},
},
} as unknown as OpencodeClient
const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
)
expect(result).toEqual(config)
expect(calls).toEqual(["global"])
})
})
describe("query keys", () => {
@@ -105,10 +105,13 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
queryFn: async () => {
if ((await protocol) !== "v1") return {}
return retry(() => sdk.global.config.get().then((x) => x.data!))
},
})
type ProjectApi = {
@@ -149,7 +152,7 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
@@ -376,7 +379,10 @@ export async function bootstrapDirectory(input: {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
}),
() =>
retry(() =>
(async () => {
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
},
}) as Session
const userMessage = (id: string, sessionID: string) =>
const userMessage = (id: string, sessionID: string, created = 1) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
}),
)
applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
store,
setStore,
push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
applyDirectoryEvent({
event: {
type: "message.updated",
properties: {
info: {
...userMessage("msg_2", sessionID),
...userMessage("msg_a", sessionID, 2),
role: "assistant",
} as Message,
},
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
store,
setStore,
push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_2).toBeUndefined()
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_a).toBeUndefined()
})
test("upserts and prunes message parts", () => {
@@ -15,6 +15,7 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
@@ -275,7 +276,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
@@ -295,8 +296,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
@@ -322,7 +323,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
@@ -345,13 +346,13 @@ export function applyDirectoryEvent(input: {
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (p) => p.id)
const next = Binary.search(list, props.partID, (part) => part.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
@@ -364,7 +365,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) break
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
import { QueryClient } from "@tanstack/solid-query"
import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
createHomeSessionIndexCache,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
homeSessionIndexSessions,
@@ -151,4 +153,33 @@ describe("Home V2 session index", () => {
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
test("removes a session from the loaded Home index", () => {
const queryClient = new QueryClient()
const cache = createHomeSessionIndexCache(queryClient, "server")
const sessions = [
{ id: "a", time: { created: 1, updated: 1 } },
{ id: "b", time: { created: 1, updated: 1 } },
] as Session[]
queryClient.setQueryData(cache.indexKey, { sessions, eventSequence: 0 })
cache.remove("a")
const index = queryClient.getQueryData<{ sessions: Session[] }>(cache.indexKey)
expect(index?.sessions.map((item) => item.id)).toEqual(["b"])
})
test("keeps the session out of the Home list when the index is not mounted", () => {
const queryClient = new QueryClient()
const cache = createHomeSessionIndexCache(queryClient, "server")
const sessions = [
{ id: "a", time: { created: 1, updated: 1 } },
{ id: "b", time: { created: 1, updated: 1 } },
] as Session[]
cache.remove("a")
expect(queryClient.getQueryData(cache.indexKey)).toBeUndefined()
expect(cache.sessions({ sessions, eventSequence: 0 }, undefined).map((item) => item.id)).toEqual(["b"])
})
})
@@ -85,6 +85,7 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
const indexKey = homeSessionIndexKey(server)
const eventsKey = homeSessionEventsKey(server)
let connected = false
const removed = new Set<string>()
return {
indexKey,
@@ -97,7 +98,8 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
},
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
return homeSessionIndexSessions(index, events)
const sessions = homeSessionIndexSessions(index, events)
return removed.size === 0 ? sessions : sessions.filter((session) => !removed.has(session.id))
},
apply(event: HomeSessionEvent) {
if (!queryClient.getQueryState(indexKey)) return
@@ -116,6 +118,16 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
}
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
},
remove(sessionID: string) {
removed.add(sessionID)
if (!queryClient.getQueryState(indexKey)) return
queryClient.setQueryData<HomeSessionIndex>(indexKey, (index) => {
if (!index) return index
const at = index.sessions.findIndex((session) => session.id === sessionID)
if (at === -1) return index
return { ...index, sessions: index.sessions.toSpliced(at, 1) }
})
},
refresh(event: Event["type"]) {
const result = homeSessionIndexRefresh(event, connected)
connected = result.connected
@@ -103,6 +103,7 @@ describe("normalizeProviderList", () => {
)
expect(result.connected).toEqual(["openai"])
expect(result.defaultModel).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(result.default).toEqual({ openai: "gpt-5" })
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
@@ -113,6 +114,10 @@ describe("normalizeProviderList", () => {
variants: { high: {} },
})
})
test("preserves an empty current default", () => {
expect(normalizeProviderList([] as ProviderListOutput["data"], [], null).defaultModel).toBeNull()
})
})
describe("directoryKey", () => {
@@ -139,6 +139,7 @@ export function normalizeProviderList(
return {
all,
connected: providers.map((provider) => provider.id),
defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null,
default: Object.fromEntries(
providers.flatMap((provider) => {
const model =
+118 -119
View File
@@ -2,100 +2,46 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import {
createDesktopNativeBundle,
detectDesktopNativeLocale,
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
type DesktopNativeBundle,
type DesktopNativeLocale,
} from "@/i18n/desktop-native"
export type Locale =
| "en"
| "zh"
| "zht"
| "ko"
| "de"
| "es"
| "fr"
| "da"
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
| "th"
| "bs"
| "tr"
export type Locale = DesktopNativeLocale
export type Direction = "ltr" | "rtl"
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
function localeDirection(locale: Locale): Direction {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
}
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
}
const LOCALES: readonly Locale[] = [
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
"br",
"th",
"tr",
]
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
}
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
tr: "language.tr",
}
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]])
@@ -121,6 +67,50 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")),
nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")),
id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")),
vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")),
it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")),
ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")),
pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")),
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
}
function loadDict(locale: Locale) {
@@ -138,42 +128,9 @@ export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
}
export function normalizeLocale(value: string): Locale {
@@ -194,12 +151,22 @@ function readStoredLocale() {
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
const initialLocale =
warm === "en"
? Promise.resolve(warm)
: loadDict(warm).then(
() => warm,
() => "en" as const,
)
export function loadInitialLocale() {
return initialLocale
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
gate: false,
init: (props: { locale?: Locale }) => {
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
@@ -210,6 +177,13 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const intl = createMemo(() => INTL[locale()])
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
const layoutLocale = createMemo(() => {
if (!layout.direction) return intl()
// Kobalte derives menu direction from locale rather than accepting a direction override.
return layout.direction === "rtl" ? "ar" : "en"
})
const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
@@ -220,24 +194,49 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value])
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
}
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
document.cookie = cookie(locale())
const value = locale()
document.documentElement.lang = intl()
document.documentElement.dir = direction()
document.cookie = cookie(value)
})
createEffect(() => {
if (!props.onNativeTranslations || dict.loading) return
const current = dict()
if (!current) return
props.onNativeTranslations(
createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]),
)
})
return {
ready,
locale,
intl,
direction,
layoutLocale,
locales: LOCALES,
label,
t,
plural,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
},
setDirection(next: Direction) {
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
},
}
},
})
+3 -4
View File
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { resolveDefaultModel } from "@/hooks/provider-catalog"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
@@ -149,10 +150,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const configuredModel = () => {
const configured = sync().data.config.model
if (!configured) return
const [providerID, modelID] = configured.split("/")
const model = { providerID, modelID }
const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model)
if (!model) return
if (validModel(model)) return model
}
+8 -3
View File
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { useParams, useSearchParams } from "@solidjs/router"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync"
@@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
platform,
settings,
language,
navigate,
}),
}),
owner ?? undefined,
@@ -217,6 +219,7 @@ function createServerNotificationState(input: {
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
navigate: (href: string) => void
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
@@ -353,7 +356,9 @@ function createServerNotificationState(input: {
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
input.navigate(href),
)
}
})
}
@@ -386,7 +391,7 @@ function createServerNotificationState(input: {
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
}
})
}
+11 -13
View File
@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { DraftStore } from "@/utils/draft-store"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -31,26 +32,23 @@ type PlatformBase = {
/** App version */
version?: string
/** Open a URL in the default browser */
openLink(url: string): void
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Open a local file URL in its default app (desktop only) */
openLocalFile?(url: string): void
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>
/** Navigate back in history */
back(): void
/** Navigate forward in history */
forward(): void
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Send a system notification */
notify(title: string, description?: string, onClick?: () => void): Promise<void>
/** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?(
@@ -67,6 +65,9 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Prompt drafts, history, and their blobs. */
draftStore?: DraftStore
/** Stable platform window identity for window-scoped persistence */
windowID?: string
@@ -91,9 +92,6 @@ type PlatformBase = {
/** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void>
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
+15 -8
View File
@@ -5,6 +5,8 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store"
import type { Platform } from "@/context/platform"
interface PartBase {
content: string
@@ -37,7 +39,7 @@ export interface ImageAttachmentPart {
filename: string
sourcePath?: string
mime: string
dataUrl: string
blob: BlobReference
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
@@ -168,9 +170,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
}
function promptStore(initial?: InitialPrompt): PromptStore {
@@ -235,17 +237,22 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
export function createPromptSession(
serverScope: ServerScope,
scope: PromptScope,
initial?: InitialPrompt,
platform?: Platform,
) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
@@ -264,6 +264,7 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("extends a current page to include the user for split assistant turns", async () => {
@@ -1497,7 +1498,7 @@ describe("server session", () => {
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([boundary, older])
expect(store.data.message.child).toEqual([older, boundary])
})
test("preserves a part update for a message being loaded from history", async () => {

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