Compare commits

..

248 Commits

Author SHA1 Message Date
Aiden Cline d1b6cbc6a0 fix(github): support immutable OIDC subjects 2026-08-04 03:29:45 +00:00
opencode-agent[bot] 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] 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 Sareen 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] 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] 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] 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] 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] 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 Sareen 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 Sareen 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 Sareen 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
opencode-agent[bot] 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] be93cec9ba fix(app): guard reentrant Solid cleanup (#39261)
Co-authored-by: Jack <jack@anoma.ly>
2026-07-28 19:07:19 +08:00
Robin Andrew 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] 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 Cline 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] 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 Dziahel 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] 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] 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
Luke Parker 35075bb466 fix(desktop): install Electron before development (#39052) 2026-07-27 14:37:42 +10:00
Luke Parker a9afed0515 fix(desktop): remove titlebar inset in fullscreen (#38793) 2026-07-27 03:54:54 +00:00
Brendan Allan 09afffeead fix(app): preserve shadowed command owners (#39044) 2026-07-27 03:45:18 +00:00
opencode-agent[bot] de78da8a23 chore: generate 2026-07-27 03:06:34 +00:00
Brendan Allan 9ca2b44235 Connect provider e2e test (#39039) 2026-07-27 11:05:21 +08:00
opencode-agent[bot] 7ffc22c0ef chore: generate 2026-07-27 02:49:22 +00:00
David Siewert 759f6afa80 fix(app): add scroll to project selector dropdown (#39016)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:48:01 +08:00
opencode 1ad63cfc6c sync release versions for v1.18.6 2026-07-27 02:47:42 +00:00
Rahul A Mistry 7d8195e8fe fix(ui): keep mutable selects open (#39027)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:46:59 +08:00
opencode-agent[bot] 7534d23551 chore: update nix node_modules hashes 2026-07-25 08:21:12 +00:00
Brendan Allan 0a6637e17a chore(app): vendor v2 client snapshot (#38818) 2026-07-25 08:07:13 +00:00
Brendan Allan 2b2b69d668 fix(app): refresh V1 MCP state (#38816) 2026-07-25 15:01:18 +08:00
opencode-agent[bot] 9e8b2171a5 fix(app): refresh V1 providers after auth (#38786)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-25 14:58:05 +08:00
opencode-agent[bot] 5e2a6257b2 chore: generate 2026-07-25 01:25:04 +00:00
Kit Langton 065dc274ec fix(core): branch-keyed repository cache with gated reference readiness (#38759) 2026-07-24 21:23:44 -04:00
opencode a85d8d23aa sync release versions for v1.18.5 2026-07-24 22:18:13 +00:00
Aiden Cline 2b2aacc939 fix(provider): generalize Claude adaptive thinking (#38757) 2026-07-24 16:44:00 -05:00
Aiden Cline 7840562d1b fix(llm): revert response message phases (#38761) 2026-07-24 16:31:38 -05:00
opencode-agent[bot] 53669cab2b chore: generate 2026-07-24 21:07:01 +00:00
Aiden Cline 4b19ea2a71 fix(llm): preserve response message phases (#38452) 2026-07-24 16:05:22 -05:00
Frank 553b42fcc7 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-24 14:15:55 -04:00
Frank e62b09e6fc zen: opus 5 2026-07-24 14:15:53 -04:00
Zach Bruggeman f51665191a fix(opencode): preserve grep symlink paths (#38581)
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
2026-07-24 12:46:53 -05:00
Zach Bruggeman e63996919b fix(opencode): preserve grep symlink paths (#38581)
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
2026-07-24 11:16:58 -05:00
Brendan Allan 909db63265 fix(app): restore optimistic timeline state (#38693) 2026-07-24 21:20:13 +08:00
opencode-agent[bot] 66495a2a22 chore: generate 2026-07-24 11:03:00 +00:00
Brendan Allan 9ba82a1b8c fix(app): gate legacy server features (#38651)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-24 18:57:51 +08:00
Brendan Allan b62806683e fix(app): preserve inline file mentions (#38663) 2026-07-24 18:28:09 +08:00
opencode-agent[bot] ae4be983cb chore: generate 2026-07-24 10:22:28 +00:00
Brendan Allan ad78ef5a4c feat(app): support current pty transport (#38463) 2026-07-24 18:20:54 +08:00
Brendan Allan 67a04787bf fix(app): gate config permission auto-accept (#38650) 2026-07-24 18:15:42 +08:00
Brendan Allan aaa42fe3bf fix(app): isolate v2 servers from legacy layout (#38649) 2026-07-24 17:56:39 +08:00
opencode-agent[bot] 3337495427 chore: generate 2026-07-24 09:13:59 +00:00
Brendan Allan 80a4fe8f39 fix(app): remove diff rendering from file-specific tabs (#38662) 2026-07-24 17:12:23 +08:00
Brendan Allan 91ed2567ef refactor(app): resolve server protocol state (#38648) 2026-07-24 16:35:10 +08:00
opencode-agent[bot] c4545ab12f chore: generate 2026-07-24 08:29:45 +00:00
Devin R Leopold 57ddfeb756 fix(app): classify existing web profiles for layout transition (#38117)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-24 08:28:27 +00:00
Brendan Allan ce7f54d5e7 fix(app): make prompt input agent toggle reactive (#38653) 2026-07-24 16:12:57 +08:00
opencode-agent[bot] bce2992729 chore: generate 2026-07-24 07:48:56 +00:00
Brendan Allan 3819848cf2 feat(app): support current review data (#38460) 2026-07-24 15:47:29 +08:00
Brendan Allan 55f4a2691a fix(app): preserve paginated timeline order (#38641) 2026-07-24 15:44:03 +08:00
Brendan Allan a48912cbb1 fix(app): restore directory-scoped session status for v1 servers (#38637) 2026-07-24 14:55:54 +08:00
opencode-agent[bot] 2ea4bb793e chore: generate 2026-07-24 06:40:05 +00:00
Brendan Allan d07323ef59 feat(app): migrate discovery workflows (#38465) 2026-07-24 14:38:39 +08:00
opencode-agent[bot] 5ce89dc2ad chore: generate 2026-07-24 06:07:04 +00:00
Brendan Allan 589ef16128 refactor(app): split home view controllers (#38607) 2026-07-24 06:05:40 +00:00
opencode-agent[bot] 386afb77e0 chore: generate 2026-07-24 04:59:34 +00:00
Brendan Allan 29af2e39ff feat(app): migrate session interactions (#38461) 2026-07-24 12:58:20 +08:00
opencode-agent[bot] 090a26a301 chore: generate 2026-07-24 03:58:09 +00:00
Brendan Allan ce9a875181 feat(app): render current session timeline (#38466) 2026-07-24 11:56:48 +08:00
Brendan Allan db88c42335 fix(app): hydrate v1 session progress (#38606) 2026-07-24 11:39:40 +08:00
opencode-agent[bot] adba484df4 chore: generate 2026-07-24 02:50:38 +00:00
Brendan Allan 37c263e153 feat(app): project current server state (#38459) 2026-07-24 10:49:16 +08:00
Jack 204f48de8b docs(zen): add Ling 3.0 Flash free model (#38503) 2026-07-24 10:22:45 +08:00
opencode-agent[bot] 743f6410f2 chore: update nix node_modules hashes 2026-07-23 18:04:46 +00:00
Aiden Cline 20589d66d5 fix(provider): preserve Mistral reasoning history (#38453) 2026-07-23 12:47:33 -05:00
opencode-agent[bot] 62e4641235 chore: generate 2026-07-23 09:23:15 +00:00
Brendan Allan e59ba24b80 feat(app): support current event transport (#38464) 2026-07-23 17:19:44 +08:00
opencode-agent[bot] 347510a73b chore: generate 2026-07-23 08:01:01 +00:00
Brendan Allan d03e0c5e54 feat(app): add dual-server compatibility (#38462) 2026-07-23 15:59:38 +08:00
opencode-agent[bot] 84c79c1399 chore: update nix node_modules hashes 2026-07-23 07:08:44 +00:00
Brendan Allan e45210c6d2 chore(app): vendor v2 promise client (#38467) 2026-07-23 14:48:33 +08:00
opencode-agent[bot] 92cede0541 chore: update nix node_modules hashes 2026-07-23 04:06:08 +00:00
Aiden Cline fada1a538f fix(provider): serialize Mistral prompt cache keys (#38448) 2026-07-22 22:53:51 -05:00
Aiden Cline 542ba88602 fix(provider): select prompt cache keys by SDK (#38424) 2026-07-22 22:37:22 -05:00
Jack 411eff73f0 feat(go): add Hy3 to Go model lineup (#38349)
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-23 00:41:48 +08:00
Aiden Cline 50eee1f5a4 fix(provider): correct MiniMax M3 thinking variants (#38330) 2026-07-22 10:38:23 -05:00
Jack 0a601cf334 fix(docs): correct Kimi K2.7 Code request limits (#38248) 2026-07-22 12:42:44 +08:00
opencode-agent[bot] c9db6e9a1f fix(app): show running shell command (#38080)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-22 11:47:42 +10:00
opencode-agent[bot] 130038eb63 fix(app): defer unavailable notification state (#38186)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-22 11:16:51 +10:00
Frank 4438f69aac zen: add security check 2026-07-21 14:51:43 -04:00
opencode-agent[bot] 76ced5418f chore: generate 2026-07-21 18:43:01 +00:00
Frank c40e3e74a7 zen: add security check 2026-07-21 14:34:00 -04:00
Frank 0317531906 zen: add laguna-2-2.1 2026-07-21 12:59:49 -04:00
opencode-agent[bot] 5f241f1cc1 chore: generate 2026-07-21 15:19:02 +00:00
Daniel Polito b513fafe83 fix(app): support nested slash command autocomplete (#38097) 2026-07-21 23:08:23 +08:00
Daniel Polito 21c4c93770 fix(ui): improve progress circle contrast (#38101) 2026-07-21 23:08:09 +08:00
Frank abc4b83f58 Zen: gemini 3.6 flash and 3.5 flash lite 2026-07-21 10:59:30 -04:00
opencode-agent[bot] cb562b2c62 chore: generate 2026-07-21 09:58:21 +00:00
Aarav Sareen 2d2339d37d feat(app): draggable project rows (#38055) 2026-07-21 17:56:42 +08:00
Aiden Cline 849c2598ab docs: generalize session title model description (#37986) 2026-07-20 14:20:13 -05:00
Aiden Cline 3033afba51 fix(provider): normalize Mistral family tool call IDs (#37982)
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
2026-07-20 13:58:49 -05:00
Matthias Reso 5a8ee27254 fix(provider): update muse-spark reasoning default and system prompt (#37562)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-20 13:15:42 -05:00
OpeOginni 4cc022481c fix(opencode): display proper session import errors (#36258) 2026-07-20 11:43:27 -05:00
opencode d36a2d8981 sync release versions for v1.18.4 2026-07-20 15:28:16 +00:00
opencode-agent[bot] 96ff82a11f fix(console): remove desktop promo overlay
Requested by: @thdxr (dax via Slack)
2026-07-20 15:28:00 +00:00
opencode-agent[bot] 4872c48c23 fix(app): complete mentions at cursor (#37941)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:53:10 +08:00
opencode-agent[bot] 47fc6f2991 fix(app): preserve mentions during paste (#37940)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:52:14 +08:00
opencode-agent[bot] da312b009c fix(app): preserve command menu drafts (#37942)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:49:39 +08:00
opencode-agent[bot] 70535a1bad chore: update nix node_modules hashes 2026-07-20 14:35:56 +00:00
Luke Parker 43e472bba7 feat(app): sync embedded terminal theme (#37931) 2026-07-20 14:22:26 +00:00
opencode-agent[bot] b67fda133a chore: update nix node_modules hashes 2026-07-20 13:24:20 +00:00
Luke Parker e2531dff0f chore(app): bump ghostty-web (#37923)
Includes the physical-pixel rendering and native Ghostty sprite work from anomalyco/ghostty-web#3.
2026-07-20 13:09:46 +00:00
opencode-agent[bot] cc3615b535 fix(app): tolerate missing message parents (#37918)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-20 12:20:41 +00:00
Luke Parker 5542415b64 fix(app): shrink Windows drawer close button (#37909) 2026-07-20 11:21:22 +00:00
Luke Parker 8bec40a816 fix(app): align Windows upgrade drawer (#37895) 2026-07-20 10:32:38 +00:00
opencode-agent[bot] 4a81e8392b fix(app): gate notification server selection (#37890)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-20 09:22:18 +00:00
opencode-agent[bot] a19b52e85b fix(app): omit empty prompt text parts (#37577)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-20 17:09:06 +10:00
opencode-agent[bot] 9105f35966 chore: generate 2026-07-20 06:50:36 +00:00
Aarav Sareen 3ba3954aa0 feat(app): review panel improvements (#36716)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-20 06:49:23 +00:00
Aarav Sareen 3fc5af6dd1 feat(app): update settings description line height (#37759) 2026-07-20 15:57:11 +10:00
opencode-agent[bot] 5586f9675e fix(app): restore model variant accessibility (#37857)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 05:20:56 +00:00
opencode-agent[bot] 3b719aba48 chore: generate 2026-07-20 04:55:12 +00:00
Qiping Li 20a3a2138e feat(opencode): use adaptive thinking effort for kimi family on anthr… (#37696)
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-19 23:54:00 -05:00
Frank cd46f22d51 sync 2026-07-19 23:55:16 -04:00
Frank cfa134f042 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 23:30:38 -04:00
Frank 61653a20a5 sync 2026-07-19 23:30:36 -04:00
opencode-agent[bot] 2a097f3af7 fix(llm): expand context overflow patterns (#37840)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:15:21 -05:00
David Hill ba4b8e21f4 feat(app): toggle debug tools from dev badge (#36689)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-20 12:22:48 +10:00
Rahul A Mistry 7985c2066a fix(app): show keybind tooltips on prompt input controls (#37824) 2026-07-20 10:22:38 +10:00
opencode-agent[bot] 67caf894e0 fix(opencode): increase OpenAI header timeout (#37770)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:41:48 -05:00
opencode-agent[bot] 78587c141b fix(app): restore question pager segments (#37575)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-19 13:00:57 +00:00
opencode-agent[bot] f5573281ca chore: generate 2026-07-19 07:46:36 +00:00
opencode-agent[bot] 4817228f96 docs(go): update Kimi K3 request estimates (#37725)
Co-authored-by: Frank <frank@anoma.ly>
2026-07-19 03:45:27 -04:00
Frank 5a5117b36c Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 02:50:38 -04:00
Frank 6a288426b8 go: support openai route 2026-07-19 02:50:36 -04:00
usrnk1 573ac85983 feat(desktop): align new session project picker (#37461)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-19 16:15:54 +10:00
Frank 1221bbbe14 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 02:08:19 -04:00
Frank 6bea0f4fa4 zen: grok 4.5 adjusted price 2026-07-19 02:08:05 -04:00
usrnk1 cc34084dfe feat(desktop): prevent overlapping composer borders (#37490) 2026-07-19 16:01:16 +10:00
opencode-agent[bot] b8142c7aa8 chore: update nix node_modules hashes 2026-07-18 17:54:17 +00:00
Simon Klee ba3f966ed0 build: bump opentui to 0.4.5 (#37616) 2026-07-18 19:40:12 +02:00
Jérôme Benoit b95fe7b2d7 fix(nix): restore desktop integration (#37197)
Co-authored-by: Test User <test@test.com>
2026-07-18 10:41:44 -05:00
Jérôme Benoit 9da9ed9de6 fix(nix): relax Bun version check for desktop build (#36767)
Co-authored-by: Test User <test@test.com>
2026-07-18 10:41:15 -05:00
opencode-agent[bot] fab2133129 chore: configure trust center domain (#36910)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-18 02:51:31 -04:00
opencode-agent[bot] 901c9e7329 fix(app): disable unavailable file navigation (#37595)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-18 15:39:15 +10:00
opencode-agent[bot] 3476e6baa5 chore: update nix node_modules hashes 2026-07-18 03:05:17 +00:00
opencode-agent[bot] 86e04d4174 revert(tui): downgrade opentui to 0.4.3 (#37582)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 21:51:48 -05:00
440 changed files with 26100 additions and 7423 deletions
+1 -6
View File
@@ -219,7 +219,6 @@ jobs:
build-electron: build-electron:
needs: needs:
- build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
@@ -316,10 +315,7 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }} RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build - name: Build
run: bun run build run: bun run build
@@ -343,8 +339,7 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }} GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} CSC_KEYCHAIN: build.keychain
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
-27
View File
@@ -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. 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 ## Issue Requirements
All issues **must** use one of our issue templates: All issues **must** use one of our issue templates:
+59 -42
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -37,6 +37,7 @@
"@dnd-kit/helpers": "0.5.0", "@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -65,7 +66,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -95,7 +96,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.3", "version": "1.18.12",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -143,7 +144,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -157,7 +158,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -193,7 +194,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -220,7 +221,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -242,7 +243,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -266,7 +267,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -286,7 +287,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.18.3", "version": "1.18.12",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -302,7 +303,7 @@
"@ai-sdk/google": "3.0.73", "@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
@@ -338,7 +339,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1", "gitlab-ai-provider": "6.12.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -380,16 +381,16 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "11.0.2", "electron-store": "11.0.2",
"electron-updater": "6.8.9", "electron-updater": "6.8.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15",
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
@@ -434,7 +435,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -448,7 +449,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -460,7 +461,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -492,7 +493,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -508,7 +509,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +540,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +559,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.18.3", "version": "1.18.12",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -577,7 +578,7 @@
"@ai-sdk/google": "3.0.73", "@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
@@ -631,7 +632,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1", "gitlab-ai-provider": "6.12.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -689,7 +690,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -765,7 +766,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -780,7 +781,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -795,9 +796,10 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -809,7 +811,6 @@
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@@ -839,7 +840,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -852,7 +853,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
@@ -886,7 +887,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -905,7 +906,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -947,7 +948,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -974,7 +975,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -1000,6 +1001,7 @@
"remend": "catalog:", "remend": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
}, },
"devDependencies": { "devDependencies": {
@@ -1025,7 +1027,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.3", "version": "1.18.12",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1074,11 +1076,13 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
}, },
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1142,6 +1146,7 @@
"shiki": "4.2.0", "shiki": "4.2.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1", "sst": "4.13.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "5.8.2", "typescript": "5.8.2",
@@ -1199,7 +1204,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -3845,13 +3850,13 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5123,6 +5128,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
@@ -5699,7 +5706,9 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6025,6 +6034,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6033,8 +6044,6 @@
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
@@ -6043,6 +6052,8 @@
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
@@ -6173,6 +6184,8 @@
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -6989,6 +7002,8 @@
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7415,6 +7430,8 @@
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+6 -2
View File
@@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: { server: {
placement: { region: "aws:us-east-2" }, placement: { region: "aws:us-east-2" },
transform: { transform: {
worker: { worker: (args) => {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
}, },
}, },
}, },
+19
View File
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev" export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage export const deployAws = $app.stage === awsStage
if ($app.stage === "production") {
new cloudflare.DnsRecord("TrustCenter", {
zoneId: zoneID,
name: "trust.opencode.ai",
type: "CNAME",
content: "3a69a5bb27875189.vercel-dns-016.com",
proxied: false,
ttl: 60,
})
new cloudflare.DnsRecord("TrustCenterVerification", {
zoneId: zoneID,
name: "opencode.ai",
type: "TXT",
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
ttl: 60,
})
}
new cloudflare.RegionalHostname("RegionalHostname", { new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain, hostname: domain,
regionKey: "us", regionKey: "us",
+38 -5
View File
@@ -8,6 +8,8 @@
makeWrapper, makeWrapper,
writableTmpDirAsHomeHook, writableTmpDirAsHomeHook,
autoPatchelfHook, autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
opencode, opencode,
}: }:
let let
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
nodejs nodejs
makeWrapper makeWrapper
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [ ]
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [ copyDesktopItems
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing. # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook darwin.autoSignDarwinBinariesHook
]; ];
@@ -38,13 +43,30 @@ stdenv.mkDerivation (finalAttrs: {
(lib.getLib stdenv.cc.cc) (lib.getLib stdenv.cc.cc)
]; ];
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
name = "ai.opencode.desktop";
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
env = opencode.env // { env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
}; };
postPatch =
# NOTE: Relax Bun version check to be a warning instead of an error
''
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
''
# https://github.com/electron/electron/issues/31121 # https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue # mac builds use a .app bundle which doesnt have this issue
postPatch = lib.optionalString stdenv.isLinux '' + lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop BASE_PATH=packages/desktop
FILES=(src/main/windows.ts) FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do for file in "''${FILES[@]}"; do
@@ -76,8 +98,7 @@ stdenv.mkDerivation (finalAttrs: {
runHook postBuild runHook postBuild
''; '';
installPhase = installPhase = ''
''
runHook preInstall runHook preInstall
'' ''
+ lib.optionalString stdenv.hostPlatform.isDarwin '' + lib.optionalString stdenv.hostPlatform.isDarwin ''
@@ -88,6 +109,18 @@ stdenv.mkDerivation (finalAttrs: {
+ lib.optionalString stdenv.hostPlatform.isLinux '' + lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
install -Dm644 resources/icons/32x32.png \
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/64x64.png \
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128.png \
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128@2x.png \
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/icon.png \
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \ --inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \ --set ELECTRON_FORCE_IS_PACKAGED 1 \
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-zAftZieWR0hWQQVkhEa826iueHaRnKxtyRodhNb+Apc=", "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
"aarch64-linux": "sha256-cTG+gB7CuiFgaYHSFbzXaXUI1C2kiugV3B9rUDRUwlg=", "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
"aarch64-darwin": "sha256-ap4Xv5ZliourkI4mcD0ktbzSI88jb4oVBv5mMK3WdUs=", "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
"x86_64-darwin": "sha256-NGRJMMnaMNrf3FkcYwr5PcfVm5thhlNLGIeMmAK0MeE=" "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
} }
} }
+3
View File
@@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0", "@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12" "@lydell/node-pty": "1.2.0-beta.12"
} }
@@ -143,12 +144,14 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.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", "@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", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+220
View File
@@ -0,0 +1,220 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -97,6 +97,7 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol,
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants] const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000) benchmark.setTimeout(180_000)
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
}, },
}) })
await page.route(`**/session/${fixture.targetID}`, (route) => await page.route(`**/session/${fixture.targetID}`, (route) => {
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }), const current = new URL(route.request().url()).pathname.startsWith("/api/")
) return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID)) await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle) await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length, parent: requests.filter((request) => request.type === "parent").length,
} }
if (mode === "candidate") { if (mode === "candidate") {
expect(requestCounts.parent).toBe(1) expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(1) expect(historyGates).toBe(0)
} }
return { metrics, requestCounts, historyGateCount: historyGates } return { metrics, requestCounts, historyGateCount: historyGates }
} }
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click() await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, { healthy: true }) return sse(route)
if (url.pathname === "/session") return json(route, [current]) if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -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)
})
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
await expect(input).toHaveText("keep me")
})
@@ -56,7 +56,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]') const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]') const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('button[title="Choose model variant"]') const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer) await expectAppVisible(composer)
await idleComposer(page) await idleComposer(page)
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog") const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible() await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click() await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked() await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
if (url.pathname === "/session") return json(route, sessions) return json(route, { data: [] })
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`) const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current) if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory, directory,
home: directory, home: directory,
}) })
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {}) return json(route, {})
}) })
} }
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -57,11 +58,15 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, { healthy: true }) return sse(route, url.pathname === "/api/event")
if (url.pathname === "/session/status") if (url.pathname === "/global/health") return json(route, {}, 404)
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/session") return json(route, [current]) if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
}) })
} }
function sse(route: Route) { function sse(route: Route, current: boolean) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
} }
@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) { async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 }) await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: "proj_review_line_comment_regression", id: "proj_review_line_comment_regression",
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff") const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click() await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1) expect((await (await diffResponse).json()).data).toHaveLength(1)
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await panel.getByRole("tab", { name: /Review/ }).click() await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click() await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
async function selectMode(page: Page, current: string, next: string) { async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click() await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click() await page.getByRole("option", { name: next }).dispatchEvent("click")
} }
async function selectFile(page: Page, file: string) { async function selectFile(page: Page, file: string) {
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -20,10 +20,12 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = [] const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1 let detailVersion = 1
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
}, },
], ],
sessionStatus: { [sessionID]: { type: "idle" } }, sessionStatus: () => sessionStatus,
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), body: JSON.stringify({
branch: "review-pane-performance",
default_branch: "dev",
}),
}), }),
) )
await page.route("**/vcs/diff**", (route) => { await page.route("**/vcs/diff**", (route) => {
@@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
), ),
}) })
}) })
await page.route("**/pty", (route) => await page.route("**/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}), }),
) )
await page.route("**/pty/pty_review_terminal", (route) => await page.route("**/pty/pty_review_terminal*", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
) )
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1") await expect(preview).toContainText("after-1")
detailVersion = 2 detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy")) events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
@@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle")) events.push(statusEvent("idle"))
await refreshedDiff await refreshedDiff
await expect(preview).toContainText("after-2") await expect(preview).toContainText("after-2")
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => { const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve releasePath = resolve
}) })
await page.route("**/path?*", async (route) => { await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await pathBlocked await pathBlocked
return route.fallback() return route.fallback()
}) })
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
}) })
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click() await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", (request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
) )
await question.getByRole("button", { name: "Submit" }).click() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST") const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click() await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ response: "once" }) expect(request.postDataJSON()).toEqual({ reply: "once" })
}) })
test("restores the draft caret before typing after a request dock closes", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -170,6 +173,7 @@ async function mockServer(
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
}) })
} }
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden" const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 }) const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined()
}) })
test("passes through non-event fetches", async ({ page }) => { test("passes through non-event fetches", async ({ page }) => {
@@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
test.setTimeout(90_000) test.setTimeout(90_000)
const events: EventPayload[] = [] const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] } const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
@@ -56,9 +57,11 @@ test("animates todo lifecycle without replaying it across session tabs", async (
default: { providerID: "opencode", modelID: "claude-opus-4-6" }, default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}, },
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [], todos: (sessionID) => todos[sessionID] ?? [],
}) })
await configurePage(page) await configurePage(page)
@@ -68,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
const dock = page.locator('[data-component="session-todo-dock"]') const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0) await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy")) events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
@@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation" const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events, events,
eventRetry: events ? 16 : undefined, eventRetry: events ? 16 : undefined,
}) })
// The child session resolves via /session/:id but is absent from the /session list, // The child session resolves by ID but is absent from the session list,
// matching a subagent session that has not been loaded into the list cache yet. // matching a subagent session that has not been loaded into the list cache yet.
await page.route( await page.route(
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) => (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), body: JSON.stringify({
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
}), }),
) )
await configurePage(page) await configurePage(page)
@@ -1,9 +1,12 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session") const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B 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 }) => { test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page) await mockServer(page)
@@ -39,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, "\\$&")}$`)) 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) { function session(id: string, title: string) {
return { return {
id, id,
@@ -52,13 +83,21 @@ function session(id: string, title: string) {
} }
async function mockServer(page: Page) { async function mockServer(page: Page) {
const sessions = [sessionA, sessionB] const sessions = [sessionA, sessionB, sessionC]
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) 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 }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, sessions) if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId) if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -66,8 +105,7 @@ async function mockServer(page: Page) {
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -89,7 +127,20 @@ async function mockServer(page: Page) {
directory: sessionA.directory, directory: sessionA.directory,
home: sessionA.directory, home: sessionA.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) => {
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
}), }),
) )
await page.route(`**/pty/${ptyID}`, (route) => await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }), body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}), }),
) )
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined) await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
}) })
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>() const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>() const release = Promise.withResolvers<void>()
const created = { count: 0 } const created = { count: 0 }
await page.route("**/pty", (route) => { await page.route("**/api/pty*", (route) => {
created.count += 1 created.count += 1
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
}) })
}) })
await page.route(/ghostty-web/, async (route) => { await page.route(/ghostty-web/, async (route) => {
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
test("focuses a terminal created from the new-terminal button", async ({ page }) => { test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 } const created = { count: 0 }
await page.route("**/pty", (route) => { await page.route("**/api/pty*", (route) => {
created.count += 1 created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(next), body: JSON.stringify({ location: ptyLocation(), data: next }),
}) })
}) })
await page.route(`**/pty/${newPtyID}`, (route) => await page.route(`**/api/pty/${newPtyID}*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
}),
) )
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }), body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}), }),
) )
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus") await expectSessionTitle(page, "Terminal composer focus")
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
) )
} }
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo(id: string, title: string) {
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => { test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}), }),
) )
await page.route("**/pty/pty_hidden_terminal", (route) => await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
) )
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const terminal = page.locator('[data-component="terminal"]') const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible() await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1) await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page) await writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -85,26 +90,33 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}), }),
) )
await page.route(`**/pty/${ptyID}`, (route) => await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
const url = new URL(route.request().url())
expect(url.searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }), body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}), })
) })
const connections: string[] = [] const connections: string[] = []
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => { await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url()) connections.push(ws.url())
}) })
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) { function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}` return `/server/${base64Encode(server)}/session/${sessionID}`
} }
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo() {
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_model_selection_flow",
worktree: directory,
vcs: "git",
name: "NewProject",
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
sandboxes: [],
},
provider: () => ({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 200_000 },
},
},
},
{
id: "opencode-go",
name: "OpenCode Go",
models: {
"go-model-1": {
id: "go-model-1",
name: "Go Model 1",
cost: { input: 1, output: 1 },
limit: { context: 200_000 },
},
},
},
],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
default: { providerID: "opencode", modelID: "free-model" },
}),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
})
await page.goto("/")
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
await page.locator("[data-directory-path]").click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})
+291 -14
View File
@@ -4,7 +4,11 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig { export interface MockServerConfig {
provider: unknown protocol?: "v1" | "v2"
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string directory: string
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
@@ -23,14 +27,13 @@ export interface MockServerConfig {
fileList?: (path: string) => unknown | Promise<unknown> fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (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
sessionStatus?: unknown sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const cursors = new Map<string, string>()
let nextCursor = 0 let nextCursor = 0
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": { "/path": {
state: config.directory, state: config.directory,
config: config.directory, config: config.directory,
@@ -54,19 +57,47 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback() if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event" || path === "/api/event") {
if (path === "/global/health") return json(route, { healthy: true }) const events = config.events?.()
if (path === "/api/session") return sse(
return json(route, { route,
data: config.sessions.map((session) => v2Session(session, config.directory)), path === "/api/event"
cursor: {}, ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
}) : [
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) ...(path === "/global/event"
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
return json(route, true)
}
if (path === "/instance/dispose" && route.request().method() === "POST") {
config.onInstanceDispose?.()
return json(route, true)
}
if (path === "/permission") if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question") if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/session/status") return json(route, config.sessionStatus ?? {}) if (path === "/session/status")
return json(
route,
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
)
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (path === "/file" && config.fileList) if (path === "/file" && config.fileList)
return json(route, await config.fileList(url.searchParams.get("path") ?? "")) return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
@@ -89,10 +120,138 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, },
data: [], data: [],
}) })
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path]) if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/) const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) { if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1]) const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -115,6 +274,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined const token = url.searchParams.get("before") ?? undefined
@@ -137,12 +314,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) { function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {} const time = session.time && typeof session.time === "object" ? session.time : {}
return { return {
id: session.id, id: session.id,
parentID: session.parentID, parentID: session.parentID,
projectID: session.projectID ?? "project", projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0, cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { time: {
@@ -157,7 +359,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
}, },
...(typeof session.path === "string" ? { subpath: session.path } : {}), subpath: session.path,
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
} }
} }
@@ -181,3 +443,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
}) })
} }
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+37 -9
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = { export type SseConnectionRecord = {
id: number id: number
url: string url: string
path: "/global/event" | "/event" path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string> headers: Record<string, string>
openedAt: number openedAt: number
endedAt?: number endedAt?: number
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`, `data: ${JSON.stringify(payload)}\n\n`,
].join("") ].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = ( const acknowledge = (
connection: Connection, connection: Connection,
bytes: number, bytes: number,
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk)) output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length) return acknowledge(connection, input.bytes.length, output.length)
} }
const encoded = input.deliveries.map((delivery) => ({ const encoded = input.deliveries.map((delivery) => {
delivery, const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
bytes: encoder.encode(frame(delivery.payload, delivery.options)), return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})) })
encoded.forEach((item) => marker(item.delivery.options?.marker)) encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) { if (input.burst) {
const bytes = encoder.encode( const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
)
connection.controller.enqueue(bytes) connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
} }
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
@@ -177,6 +193,18 @@ export async function installSseTransport<T>(
record.controller = controller record.controller = controller
connections.push(record) connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener( request.signal.addEventListener(
"abort", "abort",
() => { () => {
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.3", "version": "1.18.12",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -19,9 +19,9 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:browser", "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: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": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
@@ -53,6 +53,7 @@
"@dnd-kit/helpers": "0.5.0", "@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -81,7 +82,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
} }
describe("SerializeAddon", () => { describe("SerializeAddon", () => {
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
})
describe("ANSI color preservation", () => { describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => { test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal() const { term, addon } = createTerminal()
+9 -1
View File
@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
return { active, normal, alternate } return { active, normal, alternate }
} }
const getTerminalMode = (value: ITerminalCore, mode: number) => {
if (!isRecord(value)) return false
const terminal = value.wasmTerm
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
return terminal.getMode(mode) === true
}
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
return "" return ""
} }
let content = options?.range let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
content += options?.range
? this._serializeBufferByRange(normalBuffer, options.range, true) ? this._serializeBufferByRange(normalBuffer, options.range, true)
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+26 -1
View File
@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
import { createSessionLineage } from "@/pages/session/session-lineage" import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home" import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@@ -236,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
} }
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</>
}
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
@@ -1,5 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server), server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key server: ServerConnection.Key
opened: () => LocalProject[] opened: () => LocalProject[]
stored: () => Project[] stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
@@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
return props return props
.load(search, current.signal) .load(search, current.signal)
.then((result) => .then((result) =>
(result.data ?? []) result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived) .filter((session) => !session.time.archived)
.map((session) => { .map((session) => {
const project = const project =
@@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`, id: `session:${props.server}:${session.id}`,
type: "session" as const, type: "session" as const,
title: session.title || props.untitled(), title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory), description: project ? displayName(project) : getFilename(session.directory),
category: props.category(), category: props.category(),
directory: session.directory, directory: session.directory,
sessionID: session.id, sessionID: session.id,
+21 -3
View File
@@ -66,11 +66,18 @@ function Cell(props: {
<div <div
classList={{ classList={{
"flex min-w-0 items-center": true, "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, "justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !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.wide && !props.inline,
}} }}
>
<div
classList={{
"flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
> >
<div <div
classList={{ classList={{
@@ -91,6 +98,7 @@ function Cell(props: {
{props.value} {props.value}
</div> </div>
</div> </div>
</div>
) )
if (props.inline) { if (props.inline) {
@@ -116,16 +124,26 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
aria-pressed={props.active} aria-pressed={props.active}
classList={{ 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, "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, "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, "bg-surface-raised-base text-text-strong": props.active,
}} }}
onClick={props.onClick} onClick={props.onClick}
>
<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">FOCUS</span> <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 }}> <span
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
>
{props.active ? "ON" : "OFF"} {props.active ? "ON" : "OFF"}
</span> </span>
</span>
</button> </button>
) )
@@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server), server: ServerConnection.key(props.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,15 +28,18 @@ import {
Switch, Switch,
} from "solid-js" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -154,7 +157,7 @@ function ProviderPicker(props: {
const settings = useSettings() const settings = useSettings()
if (settings.general.newLayoutDesigns()) if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} /> return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
@@ -226,10 +229,8 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void onSelect: (provider: string) => void
onPrepare?: () => void onPrepare?: () => void
}) { }) {
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
active: undefined as string | undefined, active: undefined as string | undefined,
@@ -266,19 +267,7 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => { const connect = (provider: string) => {
props.onPrepare?.() props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider) props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
} }
const move = (event: KeyboardEvent, direction: number) => { const move = (event: KeyboardEvent, direction: number) => {
@@ -395,10 +384,16 @@ function ProviderConnection(props: {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true } const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -413,38 +408,34 @@ function ProviderConnection(props: {
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
) )
const fallback = createMemo<ProviderAuthMethod[]>(() => [ const fallback = createMemo<ConnectMethod[]>(() => [
{ {
type: "api" as const, type: "key" as const,
label: language.t("provider.connect.method.apiKey"), label: language.t("provider.connect.method.apiKey"),
}, },
]) ])
const [auth] = createResource( const [integration] = createResource(
() => props.provider, () => ({ provider: props.provider, directory: directory() }),
async () => { (input) =>
const cached = serverSync().data.provider_auth[props.provider] serverSDK()
if (cached) return cached .api.integration.get({
const res = await serverSDK().client.provider.auth() integrationID: input.provider,
if (!alive.value) return fallback() location: input.directory ? { directory: input.directory } : undefined,
serverSync().set("provider_auth", res.data ?? {}) })
return res.data?.[props.provider] ?? fallback() .then((result) => result.data),
},
) )
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const loading = createMemo(() => integration.loading)
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const methods = createMemo<ConnectMethod[]>(() => {
const cachedMethods = serverSync().data.provider_auth[props.provider] const values = integration.latest?.methods.filter(
const directMethod = (method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined )
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: directMethod as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>, promptInputs: undefined as undefined | Record<string, string>,
state: (directMethod === undefined ? "pending" : undefined) as state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -454,7 +445,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> } | { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
function dispatch(action: Action) { function dispatch(action: Action) {
@@ -508,7 +499,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey") if (value.type === "key") return language.t("provider.connect.method.apiKey")
return value.label ?? "" return value.label ?? ""
} }
@@ -518,7 +509,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1] const hint = suffix?.[1]
return { return {
label: suffix ? label.slice(0, -suffix[0].length) : label, label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
} }
} }
@@ -549,46 +540,22 @@ function ProviderConnection(props: {
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK() await serverSDK()
.client.provider.oauth.authorize( .api.integration.oauth.connect({
{ integrationID: props.provider,
providerID: props.provider, methodID: method.id,
method: index, inputs: inputs ?? {},
inputs, location: location(),
}, })
{ throwOnError: true },
)
.then((x) => { .then((x) => {
if (!alive.value) return if (!alive.value) return
const elapsed = Date.now() - start dispatch({ type: "auth.complete", authorization: x.data })
const delay = 1000 - elapsed
if (delay > 0) {
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
dispatch({ type: "auth.complete", authorization: x.data! })
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
@@ -603,9 +570,9 @@ function ProviderConnection(props: {
index: 0, index: 0,
}) })
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => { const prompts = createMemo(() => {
const value = method() const value = method()
return value?.prompts ?? [] return value?.type === "oauth" ? (value.prompts ?? []) : []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -636,10 +603,6 @@ function ProviderConnection(props: {
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -741,7 +704,9 @@ function ProviderConnection(props: {
}) })
async function complete() { async function complete() {
await serverSDK().client.global.dispose() await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@@ -805,7 +770,7 @@ function ProviderConnection(props: {
listRef = ref listRef = ref
}} }}
items={methods} items={methods}
key={(m) => m?.label} key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => { onSelect={async (selected, index) => {
if (!selected) return if (!selected) return
void selectMethod(index) void selectMethod(index)
@@ -851,13 +816,10 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().client.auth.set({ await serverSDK().api.integration.connect.key({
providerID: props.provider, integrationID: props.provider,
auth: { location: location(),
type: "api",
key: apiKey, key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
}) })
await complete() await complete()
} }
@@ -874,12 +836,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div> <div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div> <div>
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.prefix")}
<Link <ExternalLink
href="https://opencode.ai/zen" 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" 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")} {language.t("provider.connect.opencodeZen.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -891,6 +853,7 @@ function ProviderConnection(props: {
ref={apiKey} ref={apiKey}
class="!w-full" class="!w-full"
name="apiKey" name="apiKey"
data-input="provider-api-key"
placeholder={language.t("provider.connect.apiKey.placeholder")} placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value} value={formStore.value}
invalid={formStore.error !== undefined} invalid={formStore.error !== undefined}
@@ -907,7 +870,7 @@ function ProviderConnection(props: {
</div> </div>
)} )}
</Show> </Show>
<ButtonV2 type="submit" variant="contrast"> <ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")} {language.t("common.continue")}
</ButtonV2> </ButtonV2>
</form> </form>
@@ -923,9 +886,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.line2")}</div>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")} {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")} {language.t("provider.connect.opencodeZen.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -984,12 +947,13 @@ function ProviderConnection(props: {
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await serverSDK()
.client.provider.oauth.callback({ .api.integration.oauth.complete({
providerID: props.provider, integrationID: props.provider,
method: store.methodIndex, attemptID: store.authorization!.attemptID,
location: location(),
code, code,
}) })
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (result.ok) { if (result.ok) {
await complete() await complete()
@@ -1003,9 +967,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 class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div> <div>
{language.t("provider.connect.oauth.code.visit.prefix")} {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")} {language.t("provider.connect.oauth.code.visit.link")}
</Link> </ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1042,7 +1006,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")} {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 })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1076,32 +1042,46 @@ function ProviderConnection(props: {
}) })
onMount(() => { onMount(() => {
void (async () => { const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK() const result = await serverSDK()
.client.provider.oauth.callback({ .api.integration.oauth.status({
providerID: props.provider, integrationID: props.provider,
method: store.methodIndex, attemptID: authorization.attemptID,
location: location(),
}) })
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (!alive.value) return if (!alive.value) return
if (!result.ok) { if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed")) dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
dispatch({ type: "auth.error", error: message })
return return
} }
if (result.status.status === "complete") {
await complete() await complete()
})() return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
}) })
return ( return (
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")} {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 })} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div> </div>
<TextField <TextField
@@ -1178,15 +1158,15 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={method()?.type === "api"}> <Match when={method()?.type === "key"}>
<ApiAuthView /> <ApiAuthView />
</Match> </Match>
<Match when={method()?.type === "oauth"}> <Match when={method()?.type === "oauth"}>
<Switch> <Switch>
<Match when={store.authorization?.method === "code"}> <Match when={store.authorization?.mode === "code"}>
<OAuthCodeView /> <OAuthCodeView />
</Match> </Match>
<Match when={store.authorization?.method === "auto"}> <Match when={store.authorization?.mode === "auto"}>
<OAuthAutoView /> <OAuthAutoView />
</Match> </Match>
</Switch> </Switch>
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" 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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? [] const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@@ -184,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6"> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")} {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")} {language.t("provider.custom.description.link")}
</Link> </ExternalLink>
{language.t("provider.custom.description.suffix")} {language.t("provider.custom.description.suffix")}
</p> </p>
+3 -7
View File
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk().directory)
sdk() sdk()
.client.session.fork({ sessionID, messageID: item.id }) .api.session.fork({ sessionID, messageID: item.id })
.then((forked) => { .then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id }) prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.data.id}`) navigate(`/${dir}/session/${forked.id}`)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client"
import { import {
absoluteTreePath, absoluteTreePath,
activeTreeNavigation, activeTreeNavigation,
@@ -28,6 +29,7 @@ import {
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
() => async (): Promise<Path | undefined> => {
sdk.client.path if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get() .get()
.then((result) => result.data) .then((result) => result.data)
.catch(() => undefined), .catch(() => undefined)
},
{ initialValue: undefined }, { initialValue: undefined },
) )
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
) )
const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => { const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "") const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] } if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find const base = pickerRoot(cleaned) || root() || start()
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) if (!base) return { query: value, items: directories.slice(0, 5) }
.then((result) => result.data ?? []) const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
.catch(() => []) .catch(() => [])
const results = [ const results = [
...directories, ...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
] ]
return { return {
query: value, query: value,
@@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ?? existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file return sdk.api.file
.list({ directory: absolute, path: "" }) .list({ location: { directory: absolute } })
.then((result) => result.data ?? []) .then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined) .catch(() => undefined)
}) })
listings.set(key, request) listings.set(key, request)
@@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
{(suggestion, index) => ( {(suggestion, index) => (
<button <button
id={`directory-picker-v2-suggestion-${index()}`} id={`directory-picker-v2-suggestion-${index()}`}
data-directory-path={suggestion.absolute}
role="option" role="option"
aria-selected={index() === activeSuggestion()} aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined} data-active={index() === activeSuggestion() ? "" : undefined}
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -17,6 +18,8 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any server: ServerConnection.Any
} }
const RECENT_PROJECT_LIMIT = 5
type Row = { type Row = {
absolute: string absolute: string
search: string search: string
@@ -56,13 +59,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
let list: ListRef | undefined 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( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingHome() ? true : undefined),
async () => { async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path return sdk.client.path
.get() .get()
.then((x) => x.data) .then((result) => result.data)
.catch(() => undefined) .catch(() => undefined)
}, },
{ initialValue: undefined }, { initialValue: undefined },
@@ -100,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index) .sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => { .map(({ project }) => {
const row = toRow(project.worktree, home(), "recent") const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree) const name = project.name || getFilename(project.worktree)
@@ -114,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => { const items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) 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) { function resolve(absolute: string) {
@@ -162,7 +168,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const path = displayPickerPath(item.absolute, filter(), home()) const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") { if (path === "~") {
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0"> <div class="flex items-center text-14-regular min-w-0">
@@ -174,7 +180,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
) )
} }
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0"> <div class="flex items-center text-14-regular min-w-0">
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (!x || toggle.isPending) return if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name) toggle.mutate(x.name)
}} }}
> >
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name} disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => { onChange={() => {
if (toggle.isPending) return if (toggle.isPending) return
toggle.mutate(i.name) toggle.mutate(i.name)
@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</DialogHeader> </DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2"> <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col"> <div ref={listEl} class="flex min-h-0 flex-col">
<div class="flex w-full flex-col items-start pb-3"> <div data-section="free-models" class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2"> <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]"> <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")} {language.t("dialog.model.unpaid.freeModels.title")}
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
{(provider) => ( {(provider) => (
<button <button
type="button" type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none" class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{ classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]": "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
@@ -1,15 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Popover as Kobalte } from "@kobalte/core/popover"
import { import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener" import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search" import { matchesModelSearch } from "./dialog-select-model-search"
@@ -122,14 +113,13 @@ const ModelList: Component<{
} }
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
children?: JSX.Element trigger: ModelSelectorTrigger
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void onClose?: (cause: "escape" | "select") => void
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
placement="top-start" placement="top-start"
gutter={4} gutter={4}
> >
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <Kobalte.Trigger as={props.trigger} />
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <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" 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: { export function ModelSelectorPopoverV2(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
children?: JSX.Element trigger: ModelSelectorTrigger
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void onClose?: () => void
}) { }) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" }) const controller = createModelSelectorController({
let searchRef: HTMLInputElement | undefined model: props.model,
let contentRef: HTMLDivElement | undefined provider: () => props.provider,
let restoreTrigger = true 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(() => const allModels = createMemo(() =>
model model
.list() .list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) .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 {
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)) return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
}) },
const groups = createMemo(() => { groups: (models: ModelItem[]) => {
const byProvider = new Map<string, ModelItem[]>() const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) { for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
} }
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
}) },
const keys = () => [...models().map(modelKey), manageKey] current: () => {
const current = () => {
const value = model.current() const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined 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 initialActive = () => {
const selected = current() const selected = props.current()
const options = keys() const options = keys()
if (selected && options.includes(selected)) return selected if (selected && options.includes(selected)) return selected
return options[0] ?? "" return options[0] ?? ""
} }
const activeItem = () => const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined 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) => { const setOpen = (open: boolean) => {
if (open) { if (open) {
restoreTrigger = true dismiss.allowTriggerRestore()
setStore({ open: true, active: initialActive() }) setStore({ open: true, active: initialActive() })
setTimeout(() => setTimeout(() =>
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
} }
setStore({ open: false, search: "", active: "" }) 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) => { const selectModel = (item: ModelItem) => {
restoreTrigger = false dismiss.preventTriggerRestore()
setOpen(false) setOpen(false)
afterClose(() => select(item)) dismiss.afterClose(() => props.select(item))
} }
const manage = () => { const manage = () => {
restoreTrigger = false dismiss.preventTriggerRestore()
setOpen(false) setOpen(false)
afterClose(() => { dismiss.afterClose(props.onManage)
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
} }
const selectActive = () => { const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active) const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
} }
const setSearch = (value: string) => { const setSearch = (value: string) => {
const search = value.trim() const first = props.models(value)[0]
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
setStore({ search: value, active: first ? modelKey(first) : manageKey }) setStore({ search: value, active: first ? modelKey(first) : manageKey })
} }
@@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
return ( return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}> <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <MenuV2.Trigger as={props.trigger} />
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content <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" 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)} onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={() => (restoreTrigger = false)} onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={(event) => { onCloseAutoFocus={dismiss.onCloseAutoFocus}
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
restoreTrigger = false dismiss.preventTriggerRestore()
setOpen(false) setOpen(false)
afterClose(() => props.onClose?.()) dismiss.afterClose(props.onClose)
return return
} }
if (event.altKey || event.metaKey) return if (event.altKey || event.metaKey) return
@@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel> </MenuV2.GroupLabel>
<MenuV2.RadioGroup value={current()}> <MenuV2.RadioGroup value={props.current()}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<TooltipV2 <TooltipV2
@@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={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" class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("addServer", { error: language.t("dialog.server.add.error") }) setStore("addServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd() resetAdd()
if (options.navigateOnAdd === false) { if (options.navigateOnAdd === false) {
@@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") }) setStore("editServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) { if (normalized === input.original.http.url) {
server.add(conn) server.add(conn)
} else { } else {
@@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
) )
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const raw = items()
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list if (!list.length) return list
const active = current() const active = current()
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
if (props.link) platform.openLink(props.link) if (props.link) platform.openExternal(props.link)
props.onClose?.() props.onClose?.()
dialog.close() dialog.close()
} }
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => { test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
client: { api: {
find: { file: {
files: (input: { directory: string }) => { find: (input: { location?: { directory?: string } }) => {
directories.push(input.directory) directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
}, },
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) expect(directories).toEqual(["/repo", "/repo/src"])
}) })
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
})
test("identifies the next directory level to preload", () => { test("identifies the next directory level to preload", () => {
expect( expect(
preloadTreeDirectories("src/", [ preloadTreeDirectories("src/", [
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0 let current = 0
const scoped = (value: string) => { const scoped = (value: string) => {
const raw = normalizePickerDrive(value)
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
const base = args.base() const base = args.base()
if (!base) return if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" } if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home() const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw } return { directory: trimPickerPath(base), path: raw }
} }
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory) const key = trimPickerPath(directory)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.client.file const request = args.sdk.api.file
.list({ directory: key, path: "" }) .list({ location: { directory: key } })
.then((result) => result.data ?? []) .then((result) => result.data)
.catch(() => []) .catch(() => [])
.then((nodes) => .then((nodes) =>
nodes nodes
.filter((node) => node.type === "directory") .filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), .map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
) )
cache.set(key, request) cache.set(key, request)
return request return request
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.client.find const results = await args.sdk.api.file
.files({ directory: input.directory, query, type: "directory", limit: 50 }) .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data ?? []) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+15 -1
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({ if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color || "", override: store.iconOverride || "" }, icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start }, commands: { start },
}) })
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
@@ -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>
)
}
+32 -3
View File
@@ -16,6 +16,7 @@ export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
const platform = usePlatform() const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
@@ -70,12 +71,40 @@ export function TabsInfoPopup() {
</button> </button>
</div> </div>
</Show> </Show>
<DrawerContent> <DrawerContent
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4"> style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<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"> <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 July 14
</p> </p>
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}> <Show when={!windows()}>
<DrawerClose <DrawerClose
as={IconButtonV2} as={IconButtonV2}
type="button" type="button"
-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>
)
}
+90 -89
View File
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-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 { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -36,11 +36,10 @@ import {
export type PromptInputV2ComposerProps = { export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
edit?: PromptInputProps["edit"] borderUnderlay?: boolean
onEditLoaded?: PromptInputProps["onEditLoaded"]
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & { export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"] readonly model: PromptInputProps["controls"]["model"]
} }
@@ -50,14 +49,15 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
useCommands(props)
useEditHandler(props)
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={ modelControl={
<PromptInputV2ModelControl <PromptInputV2ModelControl
loading={props.controller.model.loading} loading={props.controller.model.loading}
@@ -78,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 { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -306,7 +242,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
) )
const resources = createMemo(() => const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({ Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`, id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const, kind: "resource" as const,
label: `@${resource.name}`, label: `@${resource.name}`,
path: resource.uri, path: resource.uri,
@@ -323,7 +259,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
source: { source: {
type: "resource" as const, type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client, clientName: resource.server,
uri: resource.uri, uri: resource.uri,
}, },
}, },
@@ -408,7 +344,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID) if (item?.commentID) comments.remove(item.path, item.commentID)
}, },
openAttachment: (attachment) => openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) { openContext(key) {
const item = controller.contextItem(key) const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments) if (item) openComment(item, props, sync, layout, files, comments)
@@ -432,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"), title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"), description: language.t("prompt.toast.pasteUnsupported.description"),
}), }),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) => onError: (error) =>
showToast({ showToast({
variant: "error", variant: "error",
@@ -440,21 +377,25 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}), }),
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile, getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
}, },
view: { view: {
placeholder: designPlaceholder, placeholder: designPlaceholder,
agent: get agent() {
props.controls.agents.visible && props.controls.agents.options.length > 0 return props.controls.agents.visible && props.controls.agents.options.length > 0
? { ? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current, current: () => props.controls.agents.current,
onSelect: props.controls.agents.select, onSelect: (value: string) => props.controls.agents.select(value),
keybind: () => command.keybindParts("agent.cycle"),
} }
: undefined, : undefined
},
variant: { variant: {
options: () => variants().map((value) => ({ id: value, label: value })), options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default", current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
}, },
submit: { submit: {
stopping, stopping,
@@ -465,6 +406,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}, },
}) })
Object.defineProperty(controller, "model", { get: () => props.controls.model }) 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 return controller as PromptInputV2ComposerController
} }
@@ -514,6 +511,7 @@ function PromptInputV2ModelControl(props: {
fallback={ fallback={
<ButtonV2 <ButtonV2
data-action="prompt-model" data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted" variant="ghost-muted"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@@ -527,19 +525,22 @@ function PromptInputV2ModelControl(props: {
> >
<ModelSelectorPopoverV2 <ModelSelectorPopoverV2
model={props.model} model={props.model}
triggerAs={ButtonV2} trigger={(triggerProps) => (
triggerProps={{ <ButtonV2
variant: "ghost-muted", {...triggerProps}
size: "normal", variant="ghost-muted"
style: { height: "28px" }, size="normal"
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group", style={{ height: "28px" }}
classList: { "animate-in fade-in": shouldAnimate() }, class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
"data-action": "prompt-model", classList={{ "animate-in fade-in": shouldAnimate() }}
}} data-action="prompt-model"
onClose={props.onClose} data-control-type="popover"
> >
{content()} {content()}
</ModelSelectorPopoverV2> </ButtonV2>
)}
onClose={props.onClose}
/>
</Show> </Show>
</TooltipV2> </TooltipV2>
</Show> </Show>
+16 -14
View File
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource", type: "resource",
name: resource.name, name: resource.name,
uri: resource.uri, uri: resource.uri,
client: resource.client, client: resource.server,
display: resource.name, display: resource.name,
description: resource.description, description: resource.description,
mime: resource.mimeType, mime: resource.mimeType,
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name, title: cmd.name,
description: cmd.description, description: cmd.description,
type: "custom" as const, type: "custom" as const,
source: cmd.source, // source: cmd.source,
})) }))
return [...custom, ...builtin] return [...custom, ...builtin]
@@ -1460,10 +1460,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<DockShellForm <DockShellForm
data-dock-border-underlay="legacy"
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true, "group/prompt-input": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments <PromptImageAttachments
attachments={imageAttachments()} attachments={imageAttachments()}
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
@@ -1723,15 +1723,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<ModelSelectorPopover <ModelSelectorPopover
model={props.controls.model.selection} model={props.controls.model.selection}
triggerAs={Button} trigger={(triggerProps) => (
triggerProps={{ <Button
variant: "ghost", {...triggerProps}
size: "normal", variant="ghost"
style: control(), size="normal"
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", style={control()}
"data-action": "prompt-model", class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
}} data-action="prompt-model"
onClose={restoreFocus}
> >
<Show when={props.controls.model.selection.current()?.provider?.id}> <Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1745,7 +1744,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
language.t("dialog.model.select.title")} language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover> </Button>
)}
onClose={restoreFocus}
/>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste" 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 PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
warn?: () => void warn?: () => void
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
draftStore?: DraftStore
} }
export type PromptAttachmentsInput = { export type PromptAttachmentsInput = {
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false return false
} }
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = { const attachment: ImageAttachmentPart = {
type: "image", type: "image",
id: uuid(), id: uuid(),
filename: file.name, filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined, sourcePath: input.getPathForFile?.(file) || undefined,
mime, mime,
dataUrl: url, blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
} }
target.prompt.set([...target.prompt.current(), attachment], target.cursor) target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true return true
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({ const attachments = createPromptAttachmentsCore({
...input, ...input,
draftStore: platform.draftStore,
capture: input.prompt.capture, capture: input.prompt.capture,
warn: () => { warn: () => {
showToast({ showToast({
@@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = { type BuildRequestPartsInput = {
prompt: Prompt prompt: Prompt
context: ContextFile[] context: ContextFile[]
images: ImageAttachmentPart[] images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string text: string
messageID: string messageID: string
sessionID: string sessionID: string
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
} }
export function buildRequestParts(input: BuildRequestPartsInput) { export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = [ const requestParts: PromptRequestPart[] = input.text.trim()
? [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "text", type: "text",
text: input.text, text: input.text,
}, },
] ]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => { const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path) const path = absolute(input.sessionDirectory, attachment.path)
@@ -1,7 +1,13 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist" 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 = { export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
} }
export function createPersistedPromptInputHistory() { export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted( const [normal, setNormal, normalInit] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const [shell, setShell] = persisted( const [shell, setShell, shellInit] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
createStore<PromptHistoryState>({ entries: [] }), 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, end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, 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) const copy = clonePromptParts(original)
expect(copy).not.toBe(original) expect(copy).not.toBe(original)
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
} }
> >
<img <img
src={attachment.dataUrl} src={attachment.blob.url}
alt={attachment.filename} alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass} class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
@@ -20,9 +25,14 @@ const optimistic: Array<{
const optimisticSeeded: boolean[] = [] const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = [] const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -31,7 +41,7 @@ let variant: string | undefined
let permissionServer = "server-a" let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue, prompt: promptValue,
cursor: 0, cursor: 0,
@@ -63,23 +73,39 @@ const prompt = {
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory) createdClients.push(directory)
return { return {
api: {
session: { session: {
create: async () => { create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate await createSessionGate
createdSessions.push(directory) const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return { return {
data: {
id: `session-${createdSessions.length}`, id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`, title: `New session ${createdSessions.length}`,
}, location: { directory: location },
} }
}, },
shell: async () => { prompt: async (input: unknown) => {
sentShell.push(directory) sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined } return { data: undefined }
}, },
prompt: async () => ({ data: undefined }), command: async (input: unknown) => {
promptAsync: async () => ({ data: undefined }), sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
},
session: {
command: async () => ({ data: undefined }), command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
@@ -172,6 +198,7 @@ beforeAll(async () => {
scope: "local", scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) { createClient(opts: any) {
return clientFor(opts.directory) return clientFor(opts.directory)
@@ -183,7 +210,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => ({ useSync: () => () => ({
data: { command: [] }, data: { command: commands },
session: { session: {
optimistic: { optimistic: {
add: (value: { add: (value: {
@@ -210,6 +237,9 @@ beforeAll(async () => {
session: { session: {
remember: () => undefined, remember: () => undefined,
set: () => undefined, set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
}, },
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
@@ -251,11 +281,17 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdClients.length = 0 createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0 optimisticSeeded.length = 0
promoted.length = 0 promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -264,6 +300,7 @@ beforeEach(() => {
variant = undefined variant = undefined
permissionServer = "server-a" permissionServer = "server-a"
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
@@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([ expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" }, { directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1) expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({ expect(optimistic[0]).toMatchObject({
@@ -427,6 +481,56 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model", variant: "high" },
}, },
}) })
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
}) })
test("uses an injected model selection", async () => { test("uses an injected model selection", async () => {
@@ -487,7 +591,8 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true]) expect(optimisticSeeded).toEqual([true])
}) })
}) })
@@ -20,6 +20,9 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" 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 = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -39,7 +42,7 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
client: DirectorySDK["client"] api: DirectorySDK["api"]["session"]
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
draft: FollowupDraft draft: FollowupDraft
@@ -81,20 +84,24 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
await input.client.session.command({ const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID,
command: cmd, command: cmd,
arguments: tail.join(" "), arguments: tail.join(" "),
agent: input.draft.agent, agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant, variant: input.draft.variant,
parts: images.map((attachment) => ({ },
id: Identifier.ascending("part"), files: await Promise.all(
type: "file" as const, images.map(async (attachment) => ({
mime: attachment.mime, uri: await blobDataUrl(attachment.blob, attachment.mime),
url: attachment.dataUrl, name: attachment.filename,
filename: attachment.filename,
})), })),
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -104,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
} }
const messageID = input.messageID ?? Identifier.ascending("message") 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({ const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt, prompt: input.draft.prompt,
context: input.draft.context, context: input.draft.context,
images, images: encodedImages,
text, text,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
messageID, messageID,
@@ -152,13 +165,37 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
await input.client.session.promptAsync({ await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent, agent: input.draft.agent,
model: input.draft.model, model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant, variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -210,6 +247,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message if (data?.message) return data.message
@@ -235,9 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve() return Promise.resolve()
} }
return sdk() return sdk()
.client.session.abort({ .api.session.interrupt({ sessionID })
sessionID,
})
.catch(() => {}) .catch(() => {})
} }
@@ -364,9 +400,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await client.session const created = await sdk()
.create() .api.session.create({
.then((x) => x.data ?? undefined) agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -450,12 +490,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
client.session const eventID = Event.ID.create()
.shell({ sdk()
.api.session.shell({
sessionID: session.id, sessionID: session.id,
id: eventID,
command: text,
agent, agent,
model, model,
command: text,
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -473,23 +515,25 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName) const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
client.session const messageID = Identifier.ascending("message")
.command({ serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id, sessionID: session.id,
id: messageID,
command: commandName, command: commandName,
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
model: `${model.providerID}/${model.modelID}`, model: { id: model.modelID, providerID: model.providerID, variant },
variant, files: await Promise.all(
parts: images.map((attachment) => ({ images.map(async (attachment) => ({
id: Identifier.ascending("part"), uri: await blobDataUrl(attachment.blob, attachment.mime),
type: "file" as const, name: attachment.filename,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})), })),
),
}) })
.catch((err) => { .catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -573,7 +617,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
void sendFollowupDraft({ void sendFollowupDraft({
client, api: sdk().api.session,
sync: sync(), sync: sync(),
serverSync: serverSync(), serverSync: serverSync(),
draft, draft,
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = { export type PromptProject = {
name?: string name?: string
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
}) { }) {
const [triggerReady, setTriggerReady] = createSignal(false) const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => { const setTriggerRef = (element: HTMLButtonElement) => {
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
props.controller.active() props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`) ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => { const selectProject = (project: PromptProject) => {
restoreTrigger = false dismiss.preventTriggerRestore()
props.controller.setOpen(false) props.controller.setOpen(false)
afterClose(() => props.controller.select(project)) dismiss.afterClose(() => props.controller.select(project))
} }
const selectAction = (server?: string) => { const selectAction = (server?: string) => {
restoreTrigger = false dismiss.preventTriggerRestore()
props.controller.setOpen(false) props.controller.setOpen(false)
afterClose(() => props.controller.add(server)) dismiss.afterClose(() => props.controller.add(server))
} }
const selectActive = () => { const selectActive = () => {
const project = props.controller.activeProject() const project = props.controller.activeProject()
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
) )
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null) .findLast((element) => element.offsetParent !== null)
restoreTrigger = false dismiss.preventTriggerRestore()
target?.focus() target?.focus()
queueMicrotask(() => { queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false) if (props.controller.open()) props.controller.setOpen(false)
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
modal={false} 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.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu" 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" 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()} onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)} onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={() => (restoreTrigger = false)} onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={(event) => { onCloseAutoFocus={dismiss.onCloseAutoFocus}
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -364,6 +356,7 @@ export function PromptProjectSelector(props: {
</button> </button>
</Show> </Show>
</div> </div>
<div class="max-h-[224px] overflow-y-auto">
<Show <Show
when={props.controller.servers().length > 1} when={props.controller.servers().length > 1}
fallback={ fallback={
@@ -389,7 +382,9 @@ export function PromptProjectSelector(props: {
{server!.name} {server!.name}
</div> </div>
<DropdownMenu.RadioGroup value={selectedValue()}> <DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}> <For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
>
{(project) => ( {(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} /> <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)} )}
@@ -400,6 +395,7 @@ export function PromptProjectSelector(props: {
</For> </For>
</Show> </Show>
</div> </div>
</div>
<div class="h-px bg-v2-border-border-muted" /> <div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<Show <Show
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content> </MenuV2.Content>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2> </MenuV2>
<Show when={props.branch}> <PromptGitStatus branch={props.branch} />
{(branch) => ( </>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
return props.branch
}
return (
<Show when={label()}>
{(value) => (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2 <TooltipV2
placement="top" placement="top"
value={branch()} value={value()}
class="min-w-0 max-w-[220px]" class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all" contentClass="max-w-[calc(100vw-32px)] break-all"
> >
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]"> <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span> <span class="min-w-0 truncate">{value()}</span>
</div> </div>
</TooltipV2> </TooltipV2>
</> </>
)} )}
</Show> </Show>
</>
) )
} }
@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
}> = (props) => { }> = (props) => {
const language = useLanguage() const language = useLanguage()
const key = ServerConnection.key(props.server) const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server) return (
const isDefault = () => props.controller.defaultKey() === key <ServerRowMenuView
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
)
}
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
return {
more: language.t("common.moreOptions"),
server: language.t("settings.section.server"),
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
}
}
export const ServerRowMenuView: Component<{
server: ServerConnection.Any
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return ( return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}> <MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger <MenuV2.Trigger
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
variant="ghost-muted" variant="ghost-muted"
size="small" size="small"
icon={<IconV2 name="outline-dots" />} icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")} aria-label={props.labels.more}
/> />
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content> <MenuV2.Content>
<MenuV2.Group> <MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel> <MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<MenuV2.Item <MenuV2.Item
disabled={builtin || props.server.type !== "http"} disabled={builtin() || !httpServer()}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)} onSelect={() => {
const server = httpServer()
if (server) props.onEdit(server)
}}
> >
{language.t("dialog.server.menu.edit")} {props.labels.edit}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item> </MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && isDefault()}> <Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}> <MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show> </Show>
<MenuV2.Separator /> <MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}> <MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{language.t("dialog.server.menu.delete")} {props.labels.delete}
</MenuV2.Item> </MenuV2.Item>
</MenuV2.Group> </MenuV2.Group>
</MenuV2.Content> </MenuV2.Content>
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -289,9 +289,9 @@ export function SessionHeader() {
return ( return (
<> <>
<Show when={search() && centerMount()}> <Show when={search() && centerMount()} keyed>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -308,10 +308,10 @@ export function SessionHeader() {
</span> </span>
</div> </div>
<Show when={hotkey()}> <Show when={hotkey()} keyed>
{(keybind) => ( {(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker"> <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()} {keybind}
</Keybind> </Keybind>
)} )}
</Show> </Show>
@@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal> </Portal>
)} )}
</Show> </Show>
<Show when={rightMount()}> <Show when={rightMount()} keyed>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount}>
<Show <Show
when={isV2} when={isV2}
fallback={ 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" } from "@/context/settings"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { ExternalLink } from "./external-link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
@@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => async () => {
serverSdk() const sdk = serverSdk()
.client.pty.shells() if ((await sdk.protocol) === "v1") {
.then((res) => res.data ?? []) return (await sdk.client.pty.shells()).data ?? []
.catch(() => [] as ShellOption[]), }
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -479,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={ description={
<> <>
{language.t("settings.general.row.theme.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>
</> </>
} }
> >
+270 -42
View File
@@ -30,6 +30,8 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined> type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand> 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"] const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
return value as 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>() const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" }) 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) => { export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -476,9 +745,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
) )
return ( 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="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="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 flex-col gap-4 pt-6 pb-6 max-w-[720px]">
@@ -511,43 +777,5 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</div> </div>
{groups} {groups}
</div> </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()}>
{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">{groups}</div>
</>
</Show>
) )
} }
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,8 +39,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders() const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -218,6 +221,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)} )}
</For> </For>
<Show when={protocol() === "v1"}>
<div <div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3" class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section" data-component="custom-provider-section"
@@ -243,6 +247,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
{language.t("common.connect")} {language.t("common.connect")}
</Button> </Button>
</div> </div>
</Show>
</SettingsList> </SettingsList>
<Button <Button
@@ -1,4 +1,4 @@
import { Component, createSignal, startTransition } from "solid-js" import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
export const DialogSettings: Component<{ export const DialogSettings: Component<{
sessionID?: string sessionID?: string
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
const showProviders = () => { const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />) void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 onBack={showProviders} /> <SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel"> <TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 /> <SettingsModelsV2 />
@@ -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,179 +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 { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { import { useSettings } from "@/context/settings"
monoDefault, import { ExternalLink } from "../external-link"
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" 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" import "./settings-v2.css"
let demoSoundState = { const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
cleanup: undefined as (() => void) | undefined, const fontSettings = {
timeout: undefined as NodeJS.Timeout | undefined, ui: {
run: 0, 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 = { const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
id: string const language = useLanguage()
name: string 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 = { const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
path: string const language = useLanguage()
name: string return (
acceptable: boolean <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>
type ShellSelectOption = { <SettingsRowV2
id: string title={language.t("settings.general.row.theme.title")}
value: string description={
label: string <>
} {language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
// To prevent audio from overlapping/playing very quickly when navigating the settings menus, {language.t("common.learnMore")}
// delay the playback by 100ms during quick selection changes and pause existing sounds. </ExternalLink>
const stopDemoSound = () => { </>
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
} }
clearTimeout(demoSoundState.timeout) >
demoSoundState.cleanup = undefined <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>
)
} }
const playDemoSound = (id: string | undefined) => { const FontSetting: Component<{
stopDemoSound() kind: "ui" | "code" | "terminal"
if (!id) return 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>
)
}
const run = ++demoSoundState.run const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
demoSoundState.timeout = setTimeout(() => { const language = useLanguage()
void playSoundById(id).then((cleanup) => { return (
if (demoSoundState.run !== run) { <div class="settings-v2-section">
cleanup?.() <h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
return <SettingsListV2>
} <SoundSetting kind="agent" channel={props.controller.agent} />
demoSoundState.cleanup = cleanup <SoundSetting kind="permissions" channel={props.controller.permissions} />
}) <SoundSetting kind="errors" channel={props.controller.errors} />
}, 100) </SettingsListV2>
</div>
)
}
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 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<{ export const SettingsGeneralV2: Component<{
sessionID?: string sessionID?: string
}> = (props) => { }> = (props) => {
const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const dir = createMemo(() => { const shell = createShellSettingsController()
if (!props.sessionID) return undefined const appearance = createAppearanceSettingsController()
return serverSync().session.lineage.peek(props.sessionID)?.session.directory const sounds = createSoundSettingsController()
})
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 desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false), () => desktop() && "getPinchZoomEnabled" in platform,
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: 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) => { const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked) setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked) const update = platform.setPinchZoomEnabled?.(checked)
@@ -181,52 +299,6 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked)) 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 = () => ( const InterfaceSection = () => (
<LayoutTransitionToggle <LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")} title={language.t("settings.general.row.newInterface.title")}
@@ -248,59 +320,18 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")} title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")} description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")} dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice} onDismiss={() => settings.general.dismissNewInterfaceNotice()}
/> />
) )
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
<SettingsRowV2 <LanguageSetting />
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>
<SettingsRowV2 <PermissionScopeSetting controller={permissionScope} />
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>
<SettingsRowV2 <ShellSetting controller={shell} />
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>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -411,124 +442,6 @@ export const SettingsGeneralV2: Component<{
</div> </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 = () => ( const NotificationsSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -573,68 +486,6 @@ export const SettingsGeneralV2: Component<{
</div> </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 = () => ( const UpdatesSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -656,7 +507,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} 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)} {language.t(updater.action().label)}
</ButtonV2> </ButtonV2>
</SettingsRowV2> </SettingsRowV2>
@@ -701,11 +552,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection controller={appearance} />
<NotificationsSection /> <NotificationsSection />
<SoundsSection /> <SoundsSection controller={sounds} />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css" import "./settings-v2.css"
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => { export const SettingsModelsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() 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>({ const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(), items: (_filter) => models.list(),
@@ -94,17 +102,56 @@ export const SettingsModelsV2: Component = () => {
} }
> >
<For each={list.grouped.latest}> <For each={list.grouped.latest}>
{(group) => ( {(group) => {
<div class="settings-v2-section" data-component="settings-models-provider"> const searching = () => list.filter().length > 0
<div class="settings-v2-models-group-header"> 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 <ProviderIcon
id={group.category} id={group.category}
width={PROVIDER_ICON_SIZE} width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE} height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0" class="settings-v2-models-provider-icon shrink-0"
/> />
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3> <span class="settings-v2-section-title">{group.items[0].provider.name}</span>
</div> </span>
</button>
</h3>
<Show when={expanded()}>
<SettingsListV2> <SettingsListV2>
<For each={group.items}> <For each={group.items}>
{(item) => { {(item) => {
@@ -127,8 +174,10 @@ export const SettingsModelsV2: Component = () => {
}} }}
</For> </For>
</SettingsListV2> </SettingsListV2>
</Show>
</div> </div>
)} )
}}
</For> </For>
</Show> </Show>
</Show> </Show>
@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -29,17 +29,21 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16 const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => { export const SettingsProvidersV2: Component<{
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders() const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />) void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
} }
const connected = createMemo(() => { const connected = createMemo(() => {
@@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -218,6 +224,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
)} )}
</For> </For>
<Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead"> <div class="settings-v2-provider-lead">
<ProviderIcon <ProviderIcon
@@ -231,7 +238,9 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span> <span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div> </div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p> <p class="settings-v2-provider-description">
{language.t("settings.providers.custom.description")}
</p>
</div> </div>
</div> </div>
<ButtonV2 <ButtonV2
@@ -245,6 +254,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
{language.t("common.connect")} {language.t("common.connect")}
</ButtonV2> </ButtonV2>
</div> </div>
</Show>
</SettingsListV2> </SettingsListV2>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}> <button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
@@ -133,9 +133,10 @@
} }
[data-slot="settings-v2-row-description"] { [data-slot="settings-v2-row-description"] {
margin-block: -3.5px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 1; line-height: 20px;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -270,10 +271,10 @@
} }
.settings-v2-provider-description { .settings-v2-provider-description {
margin: 0; margin-block: -3.5px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 1; line-height: 20px;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -372,14 +373,64 @@
} }
.settings-v2-models { .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 { .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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding-bottom: 8px;
} }
.settings-v2-models .settings-v2-section-title { .settings-v2-models .settings-v2-section-title {
@@ -393,6 +444,10 @@
color: var(--v2-icon-icon-base); 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"] { .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0; margin-top: 0;
} }
@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings() const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -276,7 +278,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) const sortedServers = createMemo(() => {
const list = settings.general.newLayoutDesigns()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -303,7 +310,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && ( {!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""} {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
)} )}
@@ -315,10 +322,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{lspCount() > 0 ? `${lspCount()} ` : ""} {lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")} {language.t("status.popover.tab.lsp")}
</Tabs.Trigger> </Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")} {language.t("status.popover.tab.plugins")}
</Tabs.Trigger> </Tabs.Trigger>
</Show>
</Tabs.List> </Tabs.List>
{!settings.general.newLayoutDesigns() && ( {!settings.general.newLayoutDesigns() && (
@@ -478,6 +487,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins"> <Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
@@ -497,6 +507,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>
</Show>
</Tabs> </Tabs>
</div> </div>
) )
@@ -1,11 +1,21 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
describe("serverStatusDotClass", () => { describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => { 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") 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", () => { 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") expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
}) })
@@ -26,7 +36,7 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
}) })
test("detects LSP failures that do not block chatting", () => { test("detects LSP failures that do not block chatting", () => {
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) 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,18 +1,29 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" 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: { export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]> mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]> lsp: Array<LspStatus["status"]>
}) { }) {
return ( return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") || input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error") input.lsp.some((status) => status === "error")
) )
} }
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.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-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.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base" if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-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 { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" 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 Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) 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 [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) 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(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), 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({ class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
})}`} })}`}
/> />
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) 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(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
shown: shown(), shown: shown(),
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: false,
issue: false, issue: false,
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -135,6 +152,7 @@ type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
serverHealth: boolean | undefined serverHealth: boolean | undefined
attention: boolean
issue: boolean issue: boolean
label: string label: string
onOpenChange: (value: boolean) => void onOpenChange: (value: boolean) => void
+57 -11
View File
@@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client
const url = sdk().url const url = sdk().url
const auth = connection.http const auth = connection.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
@@ -241,9 +240,10 @@ export const Terminal = (props: TerminalProps) => {
} }
} }
const pushSize = (cols: number, rows: number) => { const pushSize = async (cols: number, rows: number) => {
return client.pty if ((await sdk().protocol) === "v1") {
.update({ return sdk()
.client.pty.update({
ptyID: id, ptyID: id,
size: { cols, rows }, size: { cols, rows },
}) })
@@ -251,6 +251,16 @@ export const Terminal = (props: TerminalProps) => {
debugTerminal("failed to sync terminal size", err) debugTerminal("failed to sync terminal size", err)
}) })
} }
return sdk()
.api.pty.update({
ptyID: id,
location: { directory },
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
const getTerminalColors = (): TerminalColors => { const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light" const mode = theme.mode() === "dark" ? "dark" : "light"
@@ -292,7 +302,12 @@ export const Terminal = (props: TerminalProps) => {
const scheduleSize = (cols: number, rows: number) => { const scheduleSize = (cols: number, rows: number) => {
if (disposed) return if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return if (lastSize?.cols === cols && lastSize?.rows === rows) {
pendingSize = undefined
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
sizeTimer = undefined
return
}
pendingSize = { cols, rows } pendingSize = { cols, rows }
@@ -317,8 +332,10 @@ export const Terminal = (props: TerminalProps) => {
createEffect(() => { createEffect(() => {
const colors = terminalColors() const colors = terminalColors()
const mode = theme.mode() === "dark" ? "dark" : "light"
if (!term) return if (!term) return
setOptionIfSupported(term, "theme", colors) setOptionIfSupported(term, "theme", colors)
setOptionIfSupported(term, "colorScheme", mode)
}) })
createEffect(() => { createEffect(() => {
@@ -365,7 +382,11 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
platform.openLink(text) if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
platform.openLocalFile(text)
return
}
platform.openExternal(text)
} }
onMount(() => { onMount(() => {
@@ -396,6 +417,7 @@ export const Terminal = (props: TerminalProps) => {
} }
_ghostty = g _ghostty = g
term = t term = t
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
output = terminalWriter((data, done) => output = terminalWriter((data, done) =>
t.write(data, () => { t.write(data, () => {
done?.() done?.()
@@ -514,18 +536,30 @@ export const Terminal = (props: TerminalProps) => {
local.onConnectError?.(err) local.onConnectError?.(err)
} }
const gone = () => const gone = async () => {
client.pty if ((await sdk().protocol) === "v1") {
.get({ ptyID: id }, { throwOnError: false }) return sdk()
.client.pty.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404) .then((result) => result.response.status === 404)
.catch((err) => { .catch((err) => {
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
}
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
}
const connectToken = async () => { const connectToken = async () => {
const result = await client.pty if ((await sdk().protocol) === "v1") {
.connectToken( const result = await sdk()
.client.pty.connectToken(
{ ptyID: id, directory }, { ptyID: id, directory },
{ {
throwOnError: false, throwOnError: false,
@@ -543,6 +577,14 @@ export const Terminal = (props: TerminalProps) => {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") 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}`) throw new Error(`PTY connect ticket failed with ${result.response.status}`)
} }
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
}
const retry = (err: unknown) => { const retry = (err: unknown) => {
if (disposed) return if (disposed) return
@@ -571,11 +613,14 @@ export const Terminal = (props: TerminalProps) => {
fail(err) fail(err)
return undefined return undefined
}) })
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return if (once.value) return
if (disposed) return if (disposed) return
const socket = new WebSocket( const socket = new WebSocket(
terminalWebSocketURL({ terminalWebSocketURL({
protocol,
url, url,
id, id,
directory, directory,
@@ -595,6 +640,7 @@ export const Terminal = (props: TerminalProps) => {
tries = 0 tries = 0
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (t.getMode(2031)) t.write("\x1b[?996n")
} }
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
return !dragging && !editing && !committing return !dragging && !editing && !pending
} }
@@ -9,53 +9,86 @@
justify-content: center; 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"] { [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none; display: none;
} }
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 13.5px; gap: 6px;
} }
[data-titlebar-tab-slot] { [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative; 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 { [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
left: -6.75px; left: -3.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; 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 { [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none; display: none;
} }
[data-titlebar-tab] [data-slot="tab-close"]::before { [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
content: ""; [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none; display: none;
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
display: block; --tab-title-fade-offset: 4px;
right: 0; -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"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
[data-slot="tab-close"]::before { --tab-title-fade-offset: 24px;
right: 100%;
} }
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
@@ -1,14 +1,13 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2" import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -23,8 +22,7 @@ export function TabNavItem(props: {
server: ServerConnection.Key server: ServerConnection.Key
session: () => Session | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onTitleChange?: (title: string) => void onRename: (title: string) => Promise<void>
onTitleChangeFailed?: (title: string) => void
onClose: () => void onClose: () => void
onNavigate: () => void onNavigate: () => void
active?: boolean active?: boolean
@@ -34,13 +32,12 @@ export function TabNavItem(props: {
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
}) { }) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined let measureFrame: number | undefined
const rename = createMutation(() => ({ mutationFn: props.onRename }))
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -116,41 +113,20 @@ export function TabNavItem(props: {
selection?.addRange(range) selection?.addRange(range)
} }
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
if (committing || !editing()) return if (rename.isPending || !editing()) return
committing = true
const original = props.session()?.title ?? "" const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim() const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0 titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false) setEditing(false)
if (!save || !next || next === original) { if (!save || !next || next === original) {
committing = false
return return
} }
try { await rename.mutateAsync(next)
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
} }
createEffect(() => { createEffect(() => {
@@ -164,7 +140,7 @@ export function TabNavItem(props: {
const openRename = (event: MouseEvent) => { const openRename = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), committing)) return if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session() const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title titleEl.textContent = session.title
@@ -204,11 +180,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} 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 }} classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault() event.preventDefault()
@@ -219,7 +195,6 @@ export function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<Show when={title() !== undefined}>
<a <a
data-slot="tab-link" data-slot="tab-link"
data-titlebar-tab-link data-titlebar-tab-link
@@ -249,6 +224,7 @@ export function TabNavItem(props: {
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center"> <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show <Show
when={props.session()} when={props.session()}
keyed
fallback={ fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" /> <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
} }
@@ -256,8 +232,8 @@ export function TabNavItem(props: {
{(session) => ( {(session) => (
<SessionTabAvatar <SessionTabAvatar
project={project()} project={project()}
directory={session().directory} directory={session.directory}
sessionId={session().id} sessionId={session.id}
server={props.server} server={props.server}
/> />
)} )}
@@ -300,9 +276,8 @@ export function TabNavItem(props: {
}} }}
/> />
</a> </a>
</Show>
<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 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -360,8 +335,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} 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] 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)]" 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 }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -407,7 +382,7 @@ export function DraftTabItem(props: {
{props.title} {props.title}
</span> </span>
</a> </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 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -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 { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -15,7 +16,10 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" 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: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
@@ -23,12 +27,12 @@ function SessionTabSlot(props: {
index: () => number index: () => number
active: () => boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
serverCtx: () => ServerCtx | undefined session: () => Session | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({ const sortable = useSortable({
get id() { get id() {
return props.id return props.id
@@ -38,6 +42,47 @@ function SessionTabSlot(props: {
}, },
}) })
let ref!: HTMLDivElement 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 sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
@@ -50,8 +95,30 @@ function SessionTabSlot(props: {
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false 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(() => { createEffect(() => {
const ctx = props.serverCtx() const ctx = props.serverCtx()
const value = session() const value = session()
@@ -83,39 +150,20 @@ function SessionTabSlot(props: {
}) })
return ( return (
<div <Show when={visible()}>
ref={sortable.ref} <SessionTabSlot
data-titlebar-tab-slot tab={props.tab}
data-tab-key={props.id} id={props.id}
data-active={props.active()} index={props.index}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" active={props.active}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }} forceTruncate={props.forceTruncate}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session} session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => { onRename={rename}
const value = session() onNavigate={props.onNavigate}
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)}
onClose={props.onClose} 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 global = useGlobal()
const language = useLanguage() const language = useLanguage()
const command = useCommand()
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let resizeFrame: number | undefined 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() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
@@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: {
createEffect(() => { createEffect(() => {
props.tabs.length props.tabs.length
tabIds() visibleTabIds()
refreshOverflow() refreshOverflow()
}) })
@@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: {
props.onNavigate(tab, tabEl ?? undefined) props.onNavigate(tab, tabEl ?? undefined)
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
const current = tabIds() const current = visibleTabIds()
const source = event.operation.source const source = event.operation.source
if (event.canceled || !isSortable(source)) return if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source const { initialIndex, index } = source
if (initialIndex !== index) { 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}> <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}> <For each={props.tabs}>
{(tab, index) => { {(tab) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement 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(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: {
if (tab.type === "session") { if (tab.type === "session") {
return ( return (
<SessionTabSlot <SessionTabEntry
tab={tab} tab={tab}
id={id} id={id}
index={index} index={visibleIndex}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
serverCtx={serverCtx} serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
props.onNavigate(tab, element) props.onNavigate(tab, element)
@@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot <DraftTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={index} index={visibleIndex}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={(element) => { onNavigate={(element) => {
@@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => { command.register(() => {
const number = index() + 1 const number = index() + 1
if (number > 9) return [] if (number < 1 || number > 9) return []
return [ return [
{ {
id: `tab.${number}`, id: `tab.${number}`,
+34 -136
View File
@@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -28,32 +27,13 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40 const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36 const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = { export type TitlebarUpdate = {
version: () => string | undefined version: () => string | undefined
@@ -67,13 +47,12 @@ export function useTitlebarRightMount() {
return mount return mount
} }
export function Titlebar(props: { update?: TitlebarUpdate }) { export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const theme = useTheme()
const server = useServer() const server = useServer()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -84,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
@@ -175,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}, },
]) ])
const getWin = () => {
if (platform.platform !== "desktop") return
return currentDesktopWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const win = currentThemeWindow()
if (!win?.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
const maximize = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (interactive(e.target)) return
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
const win = getWin()
if (!win?.toggleMaximize) return
e.preventDefault()
void win.toggleMaximize().catch(() => undefined)
}
return ( return (
<header <header
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined} data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
@@ -237,16 +166,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow. // Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": mac() ? `${84 / zoom()}px` : 0, "padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows() "max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` "align-self": windows() ? "flex-start" : undefined,
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}} }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
> >
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
@@ -267,9 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => ({ route, sdk }) =>
sdk.client.session sdk.api.session
.get({ sessionID: route.sessionId }) .get({ sessionID: route.sessionId })
.then((x) => x.data) .then(normalizeSessionInfo)
.catch(() => {}), .catch(() => {}),
) )
@@ -413,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: "mod+shift+t", keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(), 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) ].filter((v) => v !== undefined)
}) })
@@ -458,11 +349,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
classList={{ classList={{
"pt-2": !bottom(), "pt-2": !bottom(),
"pb-2": bottom(), "pb-2": bottom(),
"md:pl-2": mac(), "md:pl-2": macTrafficLights(),
"md:pl-4": !mac(), "md:pl-4": !macTrafficLights(),
}} }}
> >
<ChannelIndicator /> <ChannelIndicator debugTools={props.debugTools} />
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show> </Show>
@@ -504,7 +395,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<Show when={!creating()}>
<TooltipV2 <TooltipV2
placement="bottom" placement="bottom"
value={ value={
@@ -524,12 +414,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
aria-label={language.t("command.session.new")} aria-label={language.t("command.session.new")}
/> />
</TooltipV2> </TooltipV2>
</Show>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div> </div>
) )
}} }}
@@ -542,14 +428,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<div <div
classList={{ classList={{
"flex items-center min-w-0": true, "flex items-center min-w-0": true,
"pl-2": !mac(), "pl-2": !macTrafficLights(),
}} }}
> >
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<Show when={mac()}> <Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton <IconButton
icon="menu" icon="menu"
@@ -660,9 +545,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</div> </div>
</Show> </Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div> </div>
</div> </div>
<ChannelIndicator debugTools={props.debugTools} />
</div> </div>
</div> </div>
@@ -679,12 +564,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"pr-2": !windows(), "pr-2": !windows(),
}} }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />} <div class="shrink-0" style={{ width: windowsControlsWidth() }} />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show> </Show>
</div> </div>
</div> </div>
@@ -747,12 +630,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
) )
} }
function ChannelIndicator() { function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
return ( return (
<> <>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( {["beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} {channel.toUpperCase()}
</div> </div>
)} )}
</> </>
@@ -43,7 +43,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action) runAction(entry.action)
return return
} }
if (entry.href) props.platform.openLink(entry.href) if (entry.href) props.platform.openExternal(entry.href)
} }
return ( return (
+22 -7
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command" import {
activeCommandRegistrations,
addCommandRegistration,
commandPaletteOptions,
resolveKeybindOption,
type CommandOption,
} from "./command"
const paletteOptions: CommandOption[] = [ const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" }, { id: "settings.open", title: "Open settings" },
@@ -15,22 +21,31 @@ describe("commandPaletteOptions", () => {
}) })
}) })
describe("upsertCommandRegistration", () => { describe("command registrations", () => {
test("replaces keyed registrations", () => { test("shadows keyed registrations while retaining the previous owner", () => {
const one = () => [{ id: "one", title: "One" }] const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }] const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) const registrations = addCommandRegistration([{ key: "layout", options: one }], {
key: "layout",
options: two,
})
const active = activeCommandRegistrations(registrations)
expect(next).toHaveLength(1) expect(registrations).toHaveLength(2)
expect(next[0]?.options).toBe(two) expect(active).toHaveLength(1)
expect(active[0]?.options).toBe(two)
const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two))
expect(restored).toHaveLength(1)
expect(restored[0]?.options).toBe(one)
}) })
test("keeps unkeyed registrations additive", () => { test("keeps unkeyed registrations additive", () => {
const one = () => [{ id: "one", title: "One" }] const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }] const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ options: one }], { options: two }) const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two }))
expect(next).toHaveLength(2) expect(next).toHaveLength(2)
expect(next[0]?.options).toBe(two) expect(next[0]?.options).toBe(two)
+14 -5
View File
@@ -114,9 +114,18 @@ export type CommandRegistration = {
options: Accessor<CommandOption[]> options: Accessor<CommandOption[]>
} }
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
if (entry.key === undefined) return [entry, ...registrations] return [entry, ...registrations]
return [entry, ...registrations.filter((x) => x.key !== entry.key)] }
export function activeCommandRegistrations(registrations: CommandRegistration[]) {
const keys = new Set<string>()
return registrations.filter((entry) => {
if (entry.key === undefined) return true
if (keys.has(entry.key)) return false
keys.add(entry.key)
return true
})
} }
export function parseKeybind(config: string): Keybind[] { export function parseKeybind(config: string): Keybind[] {
@@ -281,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const seen = new Set<string>() const seen = new Set<string>()
const all: CommandOption[] = [] const all: CommandOption[] = []
for (const reg of store.registrations) { for (const reg of activeCommandRegistrations(store.registrations)) {
for (const opt of reg.options()) { for (const opt of reg.options()) {
if (seen.has(opt.id)) { if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
@@ -425,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
key: id, key: id,
options, options,
} }
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) setStore("registrations", (arr) => addCommandRegistration(arr, entry))
onCleanup(() => { onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== entry)) setStore("registrations", (arr) => arr.filter((x) => x !== entry))
}) })
+7 -5
View File
@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([ const sessionFields = new Set([
@@ -15,6 +16,7 @@ const sessionFields = new Set([
"permission", "permission",
"question", "question",
"message", "message",
"session_message",
"part", "part",
"part_text_accum_delta", "part_text_accum_delta",
]) ])
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
await serverSync.session.sync(sessionID, options) await serverSync.session.sync(sessionID, options)
index(sessionID) index(sessionID)
}, },
diff: serverSync.session.diff,
todo: serverSync.session.todo, todo: serverSync.session.todo,
history: serverSync.session.history, history: serverSync.session.history,
evict(sessionID: string) { evict(sessionID: string) {
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await client.session.list() const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = (response.data ?? []) const sessions = response.data
.filter((session) => !!session?.id) .map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit) .slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
@@ -133,7 +134,8 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
current()[1]( current()[1](
"session", "session",
produce((draft) => { produce((draft) => {

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