Compare commits

..

276 Commits

Author SHA1 Message Date
Aiden Cline ecf550c88c fix(mcp): cancel timed out oauth refresh 2026-06-13 11:28:54 -05:00
opencode-agent[bot] ee5ee61db5 fix(mcp): bound oauth refresh timeout 2026-06-13 02:48:13 +00:00
opencode-agent[bot] c5fff9fb68 fix(mcp): refresh expired oauth tokens 2026-06-13 02:32:37 +00:00
opencode-agent[bot] dbbe67f066 chore: generate 2026-06-12 22:52:55 +00:00
Sebastian fff0ec294c fix(tui): prevent duplicate renderable IDs (#32110) 2026-06-13 00:51:32 +02:00
Aiden Cline cf2d1dd3e9 fix(tui): restore subtask invocation spacing (#32109) 2026-06-12 17:18:05 -05:00
Stefan Avram 27ca0f8825 docs: update North Mini Code privacy terms (#32096) 2026-06-12 16:44:25 -04:00
opencode-agent[bot] 73dbd8a312 chore: generate 2026-06-12 20:00:30 +00:00
Frank 443f103ee3 go: kimi 2.7 code 2026-06-12 15:57:13 -04:00
opencode-agent[bot] 6c36b585c3 chore: generate 2026-06-12 18:46:51 +00:00
James Long c2e6b18076 feat(core): refactor project copies for v2 (#31943) 2026-06-12 14:45:16 -04:00
opencode-agent[bot] 8d97c8d412 chore: generate 2026-06-12 18:35:01 +00:00
Adam f8b357b267 fix(data): mobile breakpoint for nav 2026-06-12 13:33:00 -05:00
Adam 621796d8ce feat(stats): add world map markers 2026-06-12 13:32:59 -05:00
Adam ba2455ecc7 feat(stats): use catalog pricing in efficiency and token costs 2026-06-12 13:32:59 -05:00
Adam 44308dfd7e fix(stats): tighten truncated label line height 2026-06-12 13:32:59 -05:00
opencode-agent[bot] b000256f57 chore: generate 2026-06-12 18:16:37 +00:00
James Long 30b2544fe1 refactor(opencode): build server from layer nodes (#32086) 2026-06-12 14:14:51 -04:00
Aiden Cline 1b096b4148 fix(opencode): clear closed MCP clients (#32084) 2026-06-12 12:39:14 -05:00
Aiden Cline be227503af fix(opencode): expose structured MCP output (#32074) 2026-06-12 10:48:00 -05:00
Adam 4ddfa7c6fa fix(stats): reorder leaderboard cards 2026-06-12 08:26:18 -05:00
Jack 2415434ad9 feat(go): promote MiniMax M3 usage limits (#31986) 2026-06-12 20:50:57 +08:00
opencode-agent[bot] bf4c647653 chore: generate 2026-06-12 09:29:10 +00:00
Aarav Sareen f35bb5184c feat(app): update oc-2 colors (#31071) 2026-06-12 09:27:36 +00:00
opencode-agent[bot] 2c5335d84c chore: generate 2026-06-12 06:47:04 +00:00
Dax Raad 7793db3ac8 fix(core): preserve credential schema compatibility 2026-06-12 02:45:25 -04:00
opencode-agent[bot] 5f77482a29 chore: generate 2026-06-12 06:16:50 +00:00
Dax 30aec297d8 refactor(core): simplify integration credentials (#31968) 2026-06-12 06:15:25 +00:00
Tony Worm a9c810cbbc fix(tui): double file content injection in commands using $ARGUMENTS (#31245)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 23:19:15 -05:00
opencode fe2e4e21d0 sync release versions for v1.17.4 2026-06-12 02:19:38 +00:00
David Hill 03a2504d3d fix(tui): shorten move session description (#31967) 2026-06-11 20:10:25 -04:00
Frank bace18ccd3 zen: make payment method off_session 2026-06-11 17:50:07 -04:00
opencode-agent[bot] cd2e6f06ee chore: generate 2026-06-11 21:07:32 +00:00
Grant Martin 7e7ad37736 feat(opencode): support cwd on local MCP servers (#30676)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 16:05:45 -05:00
Aiden Cline ca8db315a9 fix(tui): show prompt submission errors (#31949) 2026-06-11 14:33:01 -05:00
opencode-agent[bot] 9eb07ab55a chore: generate 2026-06-11 17:49:51 +00:00
Adam e07e420637 fix(stats): align deep route metadata with /data (#31930) 2026-06-11 12:47:45 -05:00
opencode-agent[bot] 84f94e6143 chore: generate 2026-06-11 17:23:18 +00:00
Aiden Cline a150424c16 fix(tui): show terminal tool failure labels (#31934) 2026-06-11 12:19:31 -05:00
Linus Schlumberger 2e71292f2f fix(gemini): prevent gemini incompatibility with some tools (#31877) 2026-06-11 11:30:50 -05:00
Kevin Dawkins e2527db3c7 fix(opencode): surface content-filter finish reason as visible error (#31745)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 10:41:11 -05:00
opencode-agent[bot] a1dee8b273 chore: update nix node_modules hashes 2026-06-11 15:39:13 +00:00
Vladimir Glafirov 04e5ca9d1b chore: bump gitlab-ai-provider to 6.9.3 and opencode-gitlab-auth to 2.1.0 (#31913) 2026-06-11 10:24:20 -05:00
opencode-agent[bot] 6dd4d1473c chore: generate 2026-06-11 15:22:48 +00:00
Dax 31c5454ee6 refactor(server): serve raw filesystem content (#31911) 2026-06-11 11:20:11 -04:00
opencode-agent[bot] 31b233e9db chore: generate 2026-06-11 15:04:14 +00:00
Dax 567d6ed1a3 fix(tui): restore legacy sync consumers (#31908) 2026-06-11 15:01:20 +00:00
James Long 2bde20c2ba test(opencode): simplify snapshot race layer wiring (#31827) 2026-06-11 10:03:46 -04:00
tobwen 92c70c9c3d fix(tui): preserve exit epilogue during scoped shutdown (#31805) 2026-06-11 12:22:48 +00:00
opencode-agent[bot] 318dbe93ba chore: update nix node_modules hashes 2026-06-11 07:42:21 +00:00
Sebastian 8bd249d8cf upgrade opentui-spinner (#31561) 2026-06-11 09:20:59 +02:00
opencode-agent[bot] 710e408709 chore: generate 2026-06-11 06:32:44 +00:00
Dax dac0dd5309 feat(core): add connector authentication (#31837) 2026-06-11 06:31:17 +00:00
Aiden Cline bf05e8a122 fix(mcp): preserve headers during auth and debug (#31802) 2026-06-10 23:12:02 -05:00
opencode-agent[bot] 51b10b128e chore: generate 2026-06-11 03:36:12 +00:00
Dax 47a45601fd refactor(tui): replace v2 sync with data context (#31826) 2026-06-10 23:34:35 -04:00
opencode-agent[bot] 69623c2b79 chore: generate 2026-06-11 02:56:49 +00:00
James Long 38536cf733 test(opencode): simplify processor layer wiring (#31823) 2026-06-10 22:55:21 -04:00
Dax 8bf0675997 feat(server): add v2 session API endpoints (#31822) 2026-06-11 02:55:01 +00:00
opencode-agent[bot] ff967e582c chore: generate 2026-06-11 02:20:38 +00:00
James Long 20bf18ffb4 test(opencode): simplify share layer wiring (#31811) 2026-06-10 22:19:06 -04:00
Dmitriy Kovalenko 51891d56e7 fix(snapshot): reuse source git objects to avoid re-hashing huge repos (#31798) 2026-06-11 00:25:55 +00:00
Dax Raad cc22646467 docs: add branch naming guidance 2026-06-10 19:09:56 -04:00
James Long eb70b6137b test(opencode): simplify test registry layer wiring (#31761) 2026-06-10 17:21:40 -04:00
opencode-agent[bot] 6e2bcafd34 chore: update nix node_modules hashes 2026-06-10 20:09:04 +00:00
Aiden Cline 07b983e82f feat(mcp): support server log notifications (#31752) 2026-06-10 14:59:36 -05:00
Simon Klee c51a1588c7 tui: fix session list search filtering (#31748)
Fix #31182
Close #31211
2026-06-10 21:47:05 +02:00
Aiden Cline f43b0d3afd fix(mcp): apply timeouts to catalog requests (#31618) 2026-06-10 14:38:08 -05:00
Vladimir Glafirov 722f4dd416 chore: pin gitlab-ai-provider to 6.9.0 (#31741) 2026-06-10 14:33:31 -05:00
Dax Raad bb82aab5c8 update opencode.jsonc 2026-06-10 15:30:56 -04:00
opencode 936363e7a8 sync release versions for v1.17.3 2026-06-10 18:00:25 +00:00
Adam 8688ed7e3e feat(web): data link 2026-06-10 12:55:44 -05:00
opencode-agent[bot] 5b54203168 chore: update nix node_modules hashes 2026-06-10 17:44:15 +00:00
Vladimir Glafirov bed780fac9 chore: bump gitlab-ai-provider to 6.9.1 (#31728) 2026-06-10 12:30:43 -05:00
Dax Raad 14ec7ed5b3 fix fff disabling logic 2026-06-10 13:29:14 -04:00
opencode 2c65273204 sync release versions for v1.17.2 2026-06-10 16:42:54 +00:00
Filip 2e0f88d0e3 fix(desktop): restore linux launcher identity (#31709) 2026-06-10 15:54:49 +00:00
opencode-agent[bot] e1073e5d18 chore: generate 2026-06-10 15:48:14 +00:00
mridul 649618c50a fix(app): restore device attachment picker (#31707) 2026-06-10 15:46:09 +00:00
Ayush Thakur 02608a4e97 fix: recover from expired enterprise auth on remote config load (#31661) 2026-06-10 11:02:09 -04:00
Aiden Cline 3ad6923c61 fix(opencode): let subagents use their own permissions (#31696) 2026-06-10 09:26:08 -05:00
Dax Raad e4300e9b74 fix(core): disable fff by default on windows 2026-06-10 10:22:00 -04:00
Dax Raad 538cfaff0d feat(core): enable fff by default 2026-06-10 10:21:25 -04:00
Dax Raad 1dad38d1b5 fix(core): do not gate fff on initial scan 2026-06-10 10:19:52 -04:00
Aiden Cline c9e2a38bf4 ci: change model from gpt-5.4-nano to gpt-5.4-mini (#31695) 2026-06-10 09:14:27 -05:00
Dax Raad 5863e1254c put fff behind flag 2026-06-10 10:13:54 -04:00
opencode 4c9abff445 sync release versions for v1.17.1 2026-06-10 11:37:58 +00:00
opencode-agent[bot] 2cf68f32b5 chore: generate 2026-06-10 10:34:23 +00:00
Luke Parker 90fb32be30 fix(core): accept deprecated reference config key (#31659) 2026-06-10 10:32:35 +00:00
Frank 97e713e8aa zen: deepseek v4 pro 2026-06-10 04:05:03 -04:00
opencode-agent[bot] 826419127a chore: update nix node_modules hashes 2026-06-10 06:07:15 +00:00
Luke Parker e0449c0b96 fix(desktop): restore macOS auto-updates (#31621) 2026-06-10 05:49:11 +00:00
opencode-agent[bot] 5e342f7114 chore: update nix node_modules hashes 2026-06-10 05:00:09 +00:00
Aiden Cline 174ab58343 fix(mcp): apply timeouts to prompts and resources (#31612) 2026-06-09 23:58:40 -05:00
Aiden Cline 954d618790 fix(opencode): support Anthropic fallback responses (#31611) 2026-06-09 23:46:01 -05:00
Aiden Cline 91073360c6 fix(mcp): make client creation failure-safe (#31595) 2026-06-09 23:24:41 -05:00
Dax 8a2cfc00c9 feat(core): add project reference guidance (#31601) 2026-06-10 04:08:26 +00:00
Brendan Allan 0fc33e2a06 feat(app): /new-session route for new design (#31457) 2026-06-10 11:35:50 +08:00
opencode 6c6ed68b5a sync release versions for v1.17.0 2026-06-10 03:12:32 +00:00
opencode-agent[bot] e9106efbdc chore: update nix node_modules hashes 2026-06-10 02:47:36 +00:00
Dmitriy Kovalenko e9e2612706 chore: Update fff to 0.9.4 (#31583) 2026-06-09 22:28:34 -04:00
opencode-agent[bot] 4ae468ff06 chore: generate 2026-06-10 02:12:40 +00:00
Dax Raad 785918262a fix(core): expose partial filesystem scan results 2026-06-09 22:10:53 -04:00
opencode-agent[bot] be8fee5c2b chore: generate 2026-06-10 02:05:59 +00:00
Dax Raad 4597c687fa fix(core): prefer shorter paths for tied search scores 2026-06-09 22:04:17 -04:00
Dax c4aa049042 fix(tui): let gutter replace current marker (#31586) 2026-06-09 21:07:11 -04:00
opencode-agent[bot] 1d46b5c33c chore: update nix node_modules hashes 2026-06-10 00:51:11 +00:00
opencode-agent[bot] 6ae6f0fe8f chore: generate 2026-06-10 00:39:58 +00:00
Dax a0409e64d8 refactor(core): unify filesystem search service (#31566) 2026-06-09 20:38:02 -04:00
opencode-agent[bot] ce4e658e3f chore: update nix node_modules hashes 2026-06-09 23:55:49 +00:00
opencode-agent[bot] 215fb39fc5 chore: generate 2026-06-09 23:36:28 +00:00
Luke Parker 1cc94bb23e fix(desktop): update Electron stack and panel layout (#31571) 2026-06-09 23:34:35 +00:00
Frank bc42187c58 zen: update email 2026-06-09 19:31:54 -04:00
Songchao Wang 80c0b06980 feat: add X-Session-Id header for proxy cache routing affinity (#31511) 2026-06-09 17:01:17 -05:00
Aiden Cline 381eabb970 refactor(mcp): simplify service helpers (#31549) 2026-06-09 16:59:24 -05:00
opencode-agent[bot] c939aa04fe chore: generate 2026-06-09 19:34:54 +00:00
Frank 9b8e356fca zen: add claude fable 5 2026-06-09 15:32:14 -04:00
James Long 07e5ea9367 feat(opencode): add typed application layer graph (#31531) 2026-06-09 15:31:31 -04:00
opencode-agent[bot] 7a54a2c49c chore: generate 2026-06-09 19:10:44 +00:00
Dax 600e405ba0 fix(opencode): restore effect error logging (#31551) 2026-06-09 19:09:04 +00:00
opencode-agent[bot] 1daf535e6b chore: update nix node_modules hashes 2026-06-09 18:45:17 +00:00
opencode-agent[bot] cc52dc396c chore: generate 2026-06-09 18:30:51 +00:00
Dax 132ef57272 refactor(core): simplify location filesystem (#31545) 2026-06-09 14:28:45 -04:00
Aiden Cline 0777cf1ccf fix(mcp): log actionable connection statuses (#31544) 2026-06-09 13:07:56 -05:00
Aiden Cline c4bc902958 fix(opencode): support Claude Fable reasoning (#31546) 2026-06-09 12:38:53 -05:00
Adam d68397b42f fix(data): timestamp formatting 2026-06-09 12:19:05 -05:00
Frank 531eebb88e zen: add north mini code model 2026-06-09 13:03:55 -04:00
Aiden Cline db9391e8a6 drop citation_options from cohere (#31543) 2026-06-09 11:53:02 -05:00
Adam f1c31f4eba fix(stats): use data branding assets 2026-06-09 11:34:38 -05:00
opencode-agent[bot] ada5b31bb0 chore: generate 2026-06-09 16:12:42 +00:00
James Long ffcb45d7c9 feat(tui): show project copy in session list (#31421) 2026-06-09 12:10:37 -04:00
Dax 6566ede935 refactor(core): consolidate references (#31539) 2026-06-09 16:08:58 +00:00
Aiden Cline 0bb677cef9 feat(opencode): configure Cohere North model (#31536) 2026-06-09 10:54:03 -05:00
opencode-agent[bot] 8415ff50cb chore: generate 2026-06-09 15:48:12 +00:00
Dax 37522185d3 refactor(tui): centralize application exit (#31524) 2026-06-09 11:46:02 -04:00
Aiden Cline 960eacebcf test(core): avoid Windows worker close race (#31532) 2026-06-09 10:31:19 -05:00
Adam b4a6419215 chore: stats -> data 2026-06-09 09:31:21 -05:00
Sebastian 7c1e61af1e leave a breadcrumb comment about batchWindow zero (#31508) 2026-06-09 13:48:59 +02:00
opencode-agent[bot] 671d193786 docs: add uninstall troubleshooting steps (#31424)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-09 10:49:18 +02:00
Simon Klee 2ababc87da core: fix idle CPU use in file logger (#31478) 2026-06-09 09:27:32 +02:00
Luke Parker 5372c63c7c fix(app): clip rounded session panels (#31462) 2026-06-09 06:22:52 +00:00
Ben Sandbrook ab701d20eb feat: add "reasoning" as interleaved field option for vLLM providers (#30477)
Co-authored-by: Ben Sandbrook <1126483+delta9000@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-08 23:16:05 -05:00
Brendan Allan 384a8f58c6 feat(app): tabs help button (#31454) 2026-06-09 12:09:08 +08:00
opencode-agent[bot] 50c9d5221f chore: generate 2026-06-09 03:57:22 +00:00
Brendan Allan 1a08ee77c7 feat(app): draft prompt state (#31452) 2026-06-09 03:56:04 +00:00
Aiden Cline 79cff288a6 fix(opencode): pass abort signal to MCP tool calls (#31455) 2026-06-08 22:47:05 -05:00
Aiden Cline 0efc334ff9 fix(opencode): paginate MCP catalogs (#31442) 2026-06-08 22:43:55 -05:00
opencode-agent[bot] 277ecc505c chore: generate 2026-06-09 02:50:41 +00:00
Brendan Allan f565ff3c09 feat(app): add draft tab support to tabs store (#31343) 2026-06-09 10:49:18 +08:00
Anthony Lau 161247c70d fix(opencode): generate reasoning variants for all OpenRouter models. (#30332)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-08 20:22:02 -05:00
Aiden Cline a86ecf3bba fix: adjust item id stripping to happen prior to request signing (#31429) 2026-06-08 17:43:48 -05:00
Aiden Cline 6e84142b59 fix(opencode): support MiniMax M3 thinking toggle (#31426) 2026-06-08 17:03:31 -05:00
James Long fc52c5ac87 fix(tui): trim select footer action highlight (#31411) 2026-06-08 17:53:06 -04:00
opencode-agent[bot] 537666149b chore: generate 2026-06-08 19:43:41 +00:00
Dax c06ad7c881 refactor(core): replace legacy logger with Effect logging (#31310) 2026-06-08 15:41:56 -04:00
Aiden Cline 0a7cb20e66 fix(opencode): await run event loop (#31389) 2026-06-08 12:47:39 -05:00
Shoubhit Dash f43209bb8c fix(session): avoid sticky prompt tool overrides (#31394) 2026-06-08 23:03:27 +05:30
Shoubhit Dash b34d9242d1 fix(core): disable fff trace logs (#31380) 2026-06-08 21:10:09 +05:30
opencode-agent[bot] 9654412021 chore: update nix node_modules hashes 2026-06-08 15:16:59 +00:00
Simon Klee 31d2fecf63 chore: upgrade OpenTUI to v0.3.4 (#31326) 2026-06-08 17:00:19 +02:00
opencode-agent[bot] f3f59db3ae chore: update nix node_modules hashes 2026-06-08 14:45:45 +00:00
Aiden Cline 89e2a23b5b fix(core): restore npm proxy agent patch (#31373) 2026-06-08 09:32:41 -05:00
opencode-agent[bot] ea5f97609a chore: generate 2026-06-08 14:15:38 +00:00
Shoubhit Dash 4119051077 fix: stabilize fff file results (#31369) 2026-06-08 19:43:38 +05:30
Adam f116a55e4a fix(stats): show new for leaderboard deltas 2026-06-08 08:47:35 -05:00
opencode-agent[bot] 89e371c94b chore: generate 2026-06-08 13:41:54 +00:00
Shoubhit Dash b1a6c40ad0 fix: speed up fff file search (#31366) 2026-06-08 19:09:55 +05:30
Jack 1772e8ee6e docs(go): update MiniMax M3 pricing (#31350) 2026-06-08 19:16:19 +08:00
Tommy D. Rossi 0050134d9e fix(session): merge per-call tool rules into session permission (#30529)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-06-08 13:02:10 +05:30
opencode-agent[bot] d46af9cf1e chore: generate 2026-06-08 07:00:27 +00:00
huangli bea56feb63 fix(lsp): resolve JDTLS root to topmost pom.xml in Java Maven multi-module projects (#28761)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-08 12:28:53 +05:30
Shoubhit Dash 685a894a6f fix(opencode): include acp pending tool input (#31321) 2026-06-08 12:20:59 +05:30
Frank 79ea379df2 zen: fix 2026-06-08 01:31:31 -04:00
Frank 4863aedf3d console: update email 2026-06-08 01:05:45 -04:00
Aiden Cline b5cb9aae7f fix(opencode): respect MCP server capabilities (#31271) 2026-06-07 21:24:41 -05:00
opencode-agent[bot] 4d09a71ef4 chore: generate 2026-06-08 02:07:18 +00:00
Filip 65a3f7f749 fix(desktop): few WSL bugs (#31095) 2026-06-08 02:05:54 +00:00
Luke Parker b1d14acc35 chore: update web and desktop code owners (#31289) 2026-06-08 12:00:51 +10:00
opencode-agent[bot] 3867fa2bad chore: generate 2026-06-07 21:27:42 +00:00
Simon Klee 07808bea12 run: make minimal mode more minimal (#31227) 2026-06-07 23:26:14 +02:00
opencode-agent[bot] 914a643ab2 chore: update nix node_modules hashes 2026-06-07 18:48:26 +00:00
opencode-agent[bot] c495635f05 chore: generate 2026-06-07 18:35:32 +00:00
Aiden Cline 8ff40133b4 chore(opencode): update MCP SDK to 1.29.0 (#31268) 2026-06-07 13:34:10 -05:00
Aiden Cline aacdb34e3f fix(opencode): avoid duplicate skill catalog (#31269) 2026-06-07 13:34:01 -05:00
Aiden Cline 233427f08e test(core): cover skill directory output (#31263) 2026-06-07 12:37:53 -05:00
Luke Parker e82542b802 fix(desktop): disable hidden agent cycling (#31207) 2026-06-07 07:54:13 +00:00
Luke Parker 2006259a02 fix(desktop): style home session scrollbar (#31202) 2026-06-07 06:19:49 +00:00
opencode-agent[bot] 472b33113e chore: generate 2026-06-07 05:58:27 +00:00
Luke Parker 218147271c feat(desktop): open attachments in active project (#31192) 2026-06-07 05:57:07 +00:00
Luke Parker a29deb1ee9 fix(app): refresh directory MCP status (#31194) 2026-06-07 05:53:23 +00:00
opencode-agent[bot] f24049736e chore: update nix node_modules hashes 2026-06-07 05:41:48 +00:00
opencode-agent[bot] 21a644fef5 chore: generate 2026-06-07 05:25:52 +00:00
Dax 106f8e94d6 refactor(tui): extract standalone package (#31193) 2026-06-07 05:24:27 +00:00
opencode-agent[bot] 7a2c49e762 chore: generate 2026-06-07 05:22:22 +00:00
Luke Parker 9b4d5b0395 feat(desktop): make updates persistent and responsive (#31191) 2026-06-07 05:20:56 +00:00
Robert Douglass f20655bef4 fix(tui): sort connect providers alphabetically (#30891)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-07 00:17:38 -05:00
opencode-agent[bot] 31c099be43 chore: update nix node_modules hashes 2026-06-07 03:41:56 +00:00
opencode-agent[bot] 155e1f20d6 chore: generate 2026-06-07 03:28:52 +00:00
Dax fe0c4f8c74 refactor(server): canonicalize service API (#31049) 2026-06-07 03:27:28 +00:00
Kit Langton 53ff1b57c9 fix(core): preserve session failure causes 2026-06-06 23:26:21 -04:00
Kit Langton 1025540fcc test(core): cover managed output read permissions (#31166) 2026-06-07 01:58:41 +00:00
Kit Langton eb9a683b40 fix(core): harden unified tool runtime (#31171) 2026-06-06 21:55:34 -04:00
Kit Langton 48c26fa039 fix(test): release Windows search handles (#31172) 2026-06-07 01:41:54 +00:00
Kit Langton 10d1e04e9b fix(core): isolate image normalization (#31165) 2026-06-06 21:24:54 -04:00
Kit Langton 12acb9a59a docs(v2): update permission rule naming (#31167) 2026-06-06 20:54:51 -04:00
Kit Langton 807c804c24 fix(core): use static tool type imports (#31170) 2026-06-06 20:54:37 -04:00
Kit Langton 660a00d317 refactor(core): unify v2 tool architecture (#31168) 2026-06-06 20:49:12 -04:00
Kit Langton effd27b239 chore(http-recorder): disable release automation (#31160) 2026-06-06 20:10:28 -04:00
fancivez 06d7840d1d docs: fix MCP header interpolation example to {env:VAR} (#31078)
Co-authored-by: wujunchen <wujunchen@baidu.com>
2026-06-06 18:33:01 -05:00
Aiden Cline 0875203a6c test: fix tool test (#31163) 2026-06-06 18:06:19 -05:00
Aiden Cline b9131aa69c fix: background agent prompting, lets kill this sleep behavior oml (#31162) 2026-06-06 17:50:33 -05:00
opencode-agent[bot] 4519a1da32 chore: update nix node_modules hashes 2026-06-06 13:57:23 +00:00
opencode-agent[bot] 77963d884a chore: generate 2026-06-06 13:44:07 +00:00
Dmitriy Kovalenko 7d3d80f840 feat(opencode): fff search tools (#27802)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-06 19:12:31 +05:30
Kit Langton 4814ab3a3d fix(core): enforce V2 tool permissions (#31061) 2026-06-06 08:00:24 -04:00
Kit Langton 747b8daafc fix(core): bound prompt cache session keys (#31062) 2026-06-06 08:00:16 -04:00
opencode-agent[bot] 1399323b78 chore: generate 2026-06-06 04:39:26 +00:00
Luke Parker bd7eb0603f feat: desktop v2 everything WSL (#23407) 2026-06-06 14:38:06 +10:00
Kit Langton 09d9cf01f9 refactor(core): simplify search root protocol (#31060) 2026-06-06 03:20:06 +00:00
opencode-agent[bot] 4ac4df448a chore: update nix node_modules hashes 2026-06-06 03:19:00 +00:00
Kit Langton 147169e9b7 refactor(core): simplify filesystem read protocol (#31058) 2026-06-06 03:08:55 +00:00
Kit Langton ceccde7e84 refactor(core): simplify filesystem mutation protocol (#31059) 2026-06-06 03:08:23 +00:00
Kit Langton 54f4974546 feat(http-recorder): prepare public beta release (#31018) 2026-06-06 03:01:26 +00:00
Aiden Cline ba57718b05 feat(opencode): support non-interactive MCP add (#31054) 2026-06-05 21:21:20 -05:00
Aiden Cline 3f0ef9b71c feat(opencode): add search to auth logout command (#31053) 2026-06-05 20:51:55 -05:00
opencode-agent[bot] fa2b63f850 chore: update nix node_modules hashes 2026-06-06 00:13:07 +00:00
Kit Langton 83dca45dd5 fix(core): make V2 reads media-aware and binary-safe (#31038) 2026-06-05 19:48:34 -04:00
Luke Parker f750deaa3e fix(app): increase project session limit and add scrolling (#31035) 2026-06-05 23:40:47 +00:00
Adam b36b85936d fix(stats): filter market share to go 2026-06-05 18:28:28 -05:00
opencode-agent[bot] 7c6adcf60f fix(core): scope v2 prompt cache by session (#31036)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-05 19:11:32 -04:00
opencode-agent[bot] 3e704d096f chore: generate 2026-06-05 23:07:02 +00:00
Aarav Sareen 1fd9c77744 feat(app): sessions list improvements (#30941) 2026-06-05 23:05:38 +00:00
opencode-agent[bot] 9ed17da55a chore: generate 2026-06-05 22:14:26 +00:00
Aarav Sareen 24347f336c feat(app): updates to project avatar (#30964) 2026-06-05 22:13:04 +00:00
opencode-agent[bot] 93a58f55ca chore: generate 2026-06-05 22:09:28 +00:00
Aarav Sareen e3a55db5b5 feat(app): improve servers UI (#30961) 2026-06-05 22:08:11 +00:00
mridul 015e79fa59 fix(session): respect directory filter with workspaces (#30804) 2026-06-05 21:41:32 +00:00
James Long d5b205657f fix(tui): inject reminder after moving session (#31027) 2026-06-05 17:33:50 -04:00
James Long a645615a49 fix(opencode): limit generated project copy names (#31022) 2026-06-05 17:26:21 -04:00
James Long 499a8a4b0c fix(tui): bootstrap new project copies (#31019) 2026-06-05 17:10:14 -04:00
opencode-agent[bot] 969bb90f69 chore: generate 2026-06-05 20:49:10 +00:00
James Long f591bf5f93 feat(tui): delete working copies from move dialog (#31017) 2026-06-05 16:47:10 -04:00
Kit Langton 025e1ac69f fix(core): validate public session model switches (#31012) 2026-06-05 16:36:26 -04:00
Kit Langton 820c984d47 fix(core): recover v2 context overflow (#31005) 2026-06-05 16:05:51 -04:00
Kit Langton c814f84c87 fix(tui): update tool spacing before layout 2026-06-05 15:56:10 -04:00
Kit Langton a57fb32d95 feat(core): expose session model switching (#31011) 2026-06-05 15:55:36 -04:00
Kit Langton 05d1104ecd fix(core): scope Vertex provider transforms (#31004) 2026-06-05 14:55:52 -04:00
opencode-agent[bot] 7ebc7ffb87 chore: generate 2026-06-05 18:37:29 +00:00
Kit Langton a9094fd059 feat(core): bound v2 tool output (#30999) 2026-06-05 14:35:19 -04:00
opencode-agent[bot] 760d523847 chore: generate 2026-06-05 18:25:57 +00:00
Kit Langton 0bdd9aa494 fix(core): preserve model request semantics (#30990) 2026-06-05 14:23:31 -04:00
Aiden Cline ca9bf7abf9 fix(opencode): honor Bedrock Mantle config (#31001) 2026-06-05 13:14:55 -05:00
Aiden Cline 3bbf8c8989 fix(opencode): terminate help output with newline (#30992) 2026-06-05 12:47:16 -05:00
Kit Langton beae7290f3 feat(core): compact v2 session context (#30986) 2026-06-05 13:17:23 -04:00
opencode-agent[bot] a7bd1cd0d0 chore: generate 2026-06-05 17:16:11 +00:00
James Long ecdfcd91ca fix(tui): show current location in working copies; order by created; change shortcut; tab to cycle actions in dialog select (#30989) 2026-06-05 13:14:13 -04:00
Kit Langton 3151e2246a fix(core): harden model selection edges (#30987) 2026-06-05 16:28:32 +00:00
Kit Langton d2204e0ff5 feat(core): honor default session models (#30982) 2026-06-05 12:10:48 -04:00
opencode-agent[bot] f26a9e8856 chore: generate 2026-06-05 16:08:20 +00:00
Kit Langton 12e38866ed feat(core): interrupt v2 session execution (#30850) 2026-06-05 16:06:40 +00:00
opencode 41bd9124f4 sync release versions for v1.16.2 2026-06-05 15:58:48 +00:00
opencode-agent[bot] 8ad44cdd22 chore: generate 2026-06-05 15:23:46 +00:00
Adam 9b09075bb7 fix(stats): add mobile chart end spacing 2026-06-05 10:22:00 -05:00
Aiden Cline 76a81ac7c4 fix(workflows): serialize desktop release uploads (#30978) 2026-06-05 10:21:22 -05:00
Kit Langton 3f64b5e621 feat(core): admit v2 skill guidance (#30843) 2026-06-05 11:19:55 -04:00
Aiden Cline cc487dd032 fix(opencode): gate reasoning summaries by provider (#30973) 2026-06-05 10:08:39 -05:00
Aiden Cline f8cf8fa983 test(opencode): remove shell timeout output race (#30974) 2026-06-05 10:08:26 -05:00
Aiden Cline 5d7157fe51 test(opencode): remove disposal event wait race (#30971) 2026-06-05 09:58:40 -05:00
opencode-agent[bot] e9aa33d4bd chore: generate 2026-06-05 14:49:06 +00:00
Kit Langton 02a5ae6585 fix(core): respect v2 default agents (#30969) 2026-06-05 10:47:31 -04:00
opencode-agent[bot] fff36b70bc chore: generate 2026-06-05 14:39:44 +00:00
Shoubhit Dash 236cfcbbc3 fix(opencode): prevent destructive edit matches (#30932) 2026-06-05 20:08:05 +05:30
Adam a8adfb6305 fix(stats): scroll model charts to latest on mobile 2026-06-05 09:33:17 -05:00
Jérôme Benoit 5c8eb0ab2e fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) 2026-06-05 09:32:01 -05:00
alberto 1e216e12dc fix(opencode): resolve Bedrock hang by using node build conditions (#30873) 2026-06-05 09:22:58 -05:00
Aiden Cline 48106b7e78 chore: bun install (#30968) 2026-06-05 09:16:53 -05:00
1218 changed files with 46913 additions and 69744 deletions
+2 -4
View File
@@ -1,5 +1,3 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
+15 -5
View File
@@ -56,14 +56,24 @@ jobs:
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
HASH=""
MAX_ATTEMPTS=3
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
[ -n "$HASH" ] && break
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s"
sleep $((ATTEMPT * 10))
fi
done
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts"
cat "$BUILD_LOG"
exit 1
fi
+23 -6
View File
@@ -334,9 +334,9 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package and publish
- name: Package
if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop
timeout-minutes: 60
env:
@@ -356,11 +356,9 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create and upload macOS .app.tar.gz
- name: Create macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac"
@@ -378,7 +376,6 @@ jobs:
exit 1
fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
@@ -464,6 +461,13 @@ jobs:
pattern: latest-yml-*
path: /tmp/latest-yml
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:
pattern: opencode-desktop-*
path: /tmp/desktop
merge-multiple: true
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
@@ -490,6 +494,19 @@ jobs:
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Upload desktop release assets
if: needs.version.outputs.release
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
shopt -s nullglob
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
if (( ${#files[@]} == 0 )); then
echo "No desktop release assets found"
exit 1
fi
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
- run: ./script/publish.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
+1 -23
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test:ci --log-order=stream --log-prefix=task
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -74,26 +74,6 @@ jobs:
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
@@ -151,7 +131,6 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
@@ -162,6 +141,5 @@ jobs:
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+1 -1
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-nano
model: opencode/gpt-5.4-mini
color: "#44BA81"
tools:
"*": false
+9 -2
View File
@@ -2,8 +2,15 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
"references": {
"effect": {
"repository": "github.com/Effect-TS/effect-smol",
"description": "Use for Effect v4 and effect-smol implementation details",
},
"opencode-local": {
"path": "~/.local/share/opencode",
"description": "Contains opencode logs and data",
},
},
"mcp": {},
"tools": {
+8 -1
View File
@@ -2,6 +2,12 @@
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
@@ -143,9 +149,10 @@ const table = sqliteTable("session", {
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
+37 -1
View File
@@ -8,6 +8,10 @@ OpenCode sessions preserve durable conversational history while assembling the r
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt
**Session History**:
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
_Avoid_: Session Context
**Context Source**:
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
_Avoid_: Prompt fragment
@@ -20,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
@@ -35,9 +39,23 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
@@ -65,18 +83,36 @@ The point immediately before a provider call, after durable input promotion and
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Example dialogue
+366 -189
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -165,7 +165,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", {
domain: `stats.${domain}`,
link: [database, EMAILOCTOPUS_API_KEY],
environment: {
PUBLIC_URL: `https://${domain}/stats`,
PUBLIC_URL: `https://${domain}/data`,
},
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=",
"aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=",
"aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=",
"x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68="
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
}
}
+6 -15
View File
@@ -39,9 +39,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -52,7 +52,7 @@
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.6",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
@@ -133,24 +133,15 @@
"electron"
],
"overrides": {
"@aws-sdk/credential-provider-cognito-identity": "3.972.22",
"@aws-sdk/credential-provider-env": "3.972.25",
"@aws-sdk/credential-provider-http": "3.972.27",
"@aws-sdk/credential-provider-ini": "3.972.29",
"@aws-sdk/credential-provider-login": "3.972.29",
"@aws-sdk/credential-provider-node": "3.972.30",
"@aws-sdk/credential-provider-process": "3.972.25",
"@aws-sdk/credential-provider-sso": "3.972.29",
"@aws-sdk/credential-provider-web-identity": "3.972.29",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@smithy/shared-ini-file-loader": "4.4.8",
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.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",
"@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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
+4 -3
View File
@@ -1,11 +1,13 @@
{
"name": "@opencode-ai/app",
"version": "1.16.0",
"version": "1.17.4",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -16,8 +18,7 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
+1 -7
View File
@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/test-results",
@@ -24,7 +18,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
+92 -15
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -43,24 +43,88 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider } from "@/context/tabs"
import DirectoryLayout from "@/pages/directory-layout"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk.directory) return
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ preload: Session.preload },
)
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const server = useServer()
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
createEffect(() => {
const current = draft()
if (current && current.server !== server.key) server.setActive(current.server)
})
// Key on the directory so retargeting the draft's project re-instantiates the
// SDK/data providers for the new directory while keeping the same draft id.
const directory = () => draft()?.directory
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
)
}
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -69,9 +133,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -142,6 +204,18 @@ function SessionProviders(props: ParentProps) {
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
@@ -171,11 +245,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@@ -334,6 +410,7 @@ export function AppInterface(props: {
)}
>
<Route path="/" component={HomeRoute} />
<Route path="/new-session" component={DraftRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -399,6 +399,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
@@ -1,13 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useMcpToggle } from "@/context/mcp"
const statusLabels = {
connected: "mcp.status.connected",
@@ -19,10 +16,7 @@ const statusLabels = {
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
const toggle = useMcpToggle()
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@@ -189,7 +189,7 @@ export function DialogSelectServer() {
)
}
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
@@ -265,6 +265,11 @@ export function useServerManagementController(options: { onSelect?: () => void }
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
@@ -504,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void }
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
@@ -0,0 +1,7 @@
import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
return "server" as const
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { directoryPickerKind } from "./directory-picker-policy"
const local = {
type: "sidecar",
variant: "base",
http: { url: "http://localhost:4096" },
} as const
const remote = {
type: "ssh",
host: "example.test",
http: { url: "http://localhost:4096" },
} as const
describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -0,0 +1,29 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
type DirectoryPickerInput = {
server: ServerConnection.Any
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}
export function useDirectoryPicker() {
const platform = usePlatform()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
dialog.show(
() => <DialogSelectDirectory {...input} />,
() => input.onSelect(null),
)
}
}
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@/context/file", () => ({
useFile: () => ({
@@ -0,0 +1,54 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
)
}
+39 -14
View File
@@ -31,9 +31,10 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -56,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
@@ -72,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
@@ -139,7 +142,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const tabsStore = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
@@ -465,7 +471,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
const pick = () => {
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
}
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
@@ -625,6 +644,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
key: atKey,
filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => {
if (item.type === "agent") return "agent"
if (item.recent) return "recent"
@@ -1075,7 +1095,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1364,9 +1384,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
layout.projects.open(worktree)
server.projects.touch(worktree)
// On the draft route, retarget the existing draft in place so we keep the same
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
const draftID = search.draftId
if (draftID) {
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
restoreFocus()
return
}
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const addProject = () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
@@ -1374,15 +1404,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!directory) return
selectProject(directory)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
() => select(null),
)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
onSelect: select,
})
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { attachmentMime, pickAttachmentFiles } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
})
})
describe("pickAttachmentFiles", () => {
test("reads the current project directory for every native picker invocation", async () => {
const paths: string[] = []
const files: File[] = []
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
let directory = "C:\\Projects\\LoremIpsum"
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
paths.push(options?.defaultPath ?? "")
await onFile?.(file)
}
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
directory = "C:\\Projects\\DolorSit"
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
expect(files).toEqual([file, file])
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
})
test("uses the browser file input when no native picker exists", async () => {
let fallback = 0
pickAttachmentFiles({
directory: () => "/projects/consectetur-adipiscing",
fallback: () => {
fallback += 1
},
onFile: async () => undefined,
onError: () => undefined,
})
expect(fallback).toBe(1)
})
test("reports native picker failures without rejecting", async () => {
const error = new Error("picker unavailable")
const errors: unknown[] = []
const handled = Promise.withResolvers<void>()
pickAttachmentFiles({
picker: async () => Promise.reject(error),
directory: () => "C:\\Projects\\LoremIpsum",
fallback: () => undefined,
onFile: async () => undefined,
onError: (cause) => {
errors.push(cause)
handled.resolve()
},
})
await handled.promise
expect(errors).toEqual([error])
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
@@ -2,6 +2,38 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
export { ACCEPTED_FILE_TYPES }
type AttachmentPicker = (
options: {
defaultPath?: string
multiple?: boolean
accept?: string[]
},
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
export function pickAttachmentFiles(input: {
picker?: AttachmentPicker
directory: () => string
fallback: () => void
onFile: (file: File) => Promise<unknown>
onError: (error: unknown) => void
}) {
if (!input.picker) {
input.fallback()
return
}
void input
.picker(
{
defaultPath: input.directory(),
multiple: true,
accept: ACCEPTED_FILE_TYPES,
},
input.onFile,
)
.catch(input.onError)
}
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
@@ -61,6 +61,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -103,6 +105,16 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
}))
mock.module("@/context/tabs", () => ({
useTabs: () => ({
promoteDraft: () => undefined,
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -213,6 +215,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const errorMessage = (err: unknown) => {
@@ -381,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
const draftID = search.draftId
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) {
@@ -0,0 +1,59 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController>
onEdit: (server: ServerConnection.Http) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
return (
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
>
{language.t("dialog.server.menu.edit")}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
</SettingsList>
@@ -0,0 +1,129 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import "./settings-v2.css"
export const DialogServerV2: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController({
onSelect: () => dialog.close(),
navigateOnAdd: false,
})
const [opened, setOpened] = createSignal(false)
onMount(() => {
if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
setOpened(true)
})
onCleanup(() => {
controller.resetForm()
})
createEffect(() => {
if (!opened()) return
if (controller.isFormMode()) return
dialog.close()
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
controller.submitForm()
}
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save")
}
return (
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formValue()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()}
disabled={controller.formBusy()}
autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formName()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formUsername()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInputV2
type="password"
appearance="large"
class="!w-full self-stretch"
value={controller.formPassword()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
{submitLabel()}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServers } from "../settings-servers"
import { SettingsServersV2 } from "./servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -33,16 +33,16 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
@@ -68,7 +68,7 @@ export const DialogSettings: Component = () => {
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServers />
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
monoFontFamily,
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
@@ -0,0 +1,143 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
.go(query, items, {
keys: [(item) => serverName(item), (item) => item.http.url],
})
.map((result) => result.obj)
})
const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
}
return (
<>
<div
class="settings-v2-tab-header settings-v2-servers-header"
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
<WslAddServerButton />
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.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>
</Show>
</div>
<div class="settings-v2-tab-body settings-v2-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
fallback={
<div class="settings-v2-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-v2-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.status()[key]
const isDefault = () => controller.defaultKey() === key
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta">
<Show when={health()?.version}>v{health()?.version}</Show>
<Show when={health()?.version && item.type === "http"}> </Show>
<Show
when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
>
{item.http.username}
</Show>
</span>
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</>
)
}
@@ -511,3 +511,144 @@
.settings-v2-shortcuts-status-filter {
color: var(--v2-text-text-base);
}
.settings-v2-tab-body.settings-v2-servers {
gap: 0;
}
.settings-v2-tab-header.settings-v2-servers-header {
padding-bottom: 24px;
}
.settings-v2-servers-header .settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
gap: 24px;
padding-bottom: 24px;
}
.settings-v2-servers [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-servers-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-servers-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.settings-v2-servers-lead {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
}
.settings-v2-servers-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-servers-name {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-servers-meta {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-servers-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-v2-servers-status-filter {
color: var(--v2-text-text-base);
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
height: auto;
border-radius: 8px;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
align-items: stretch;
width: 100%;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 0;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
padding: 24px;
}
.settings-v2-server-dialog-label {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-server-dialog-error {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-state-fg-danger);
}
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -11,16 +10,12 @@ import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
const pollMs = 10_000
import { useMcpToggle } from "@/context/mcp"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -60,7 +55,7 @@ const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
url: undefined as string | undefined,
key: undefined as ServerConnection.Key | undefined,
tick: 0,
})
@@ -69,7 +64,7 @@ const useDefaultServerKey = (
let dead = false
const result = get?.()
if (!result) {
setState("url", undefined)
setState("key", undefined)
onCleanup(() => {
dead = true
})
@@ -79,7 +74,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined)
setState("key", next ?? undefined)
})
onCleanup(() => {
dead = true
@@ -87,7 +82,7 @@ const useDefaultServerKey = (
return
}
setState("url", normalizeServerUrl(result))
setState("key", ServerConnection.Key.make(result))
onCleanup(() => {
dead = true
})
@@ -95,45 +90,12 @@ const useDefaultServerKey = (
return {
key: () => {
const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
return state.key
},
refresh: () => setState("tick", (value) => value + 1),
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
@@ -160,7 +122,6 @@ export function StatusPopoverServerBody() {
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
@@ -316,7 +277,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggleMutation()
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
+80 -5
View File
@@ -280,7 +280,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
if (route.type === "draft") {
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
}
if (route.type === "session") {
const main = tabsStore.find(
@@ -447,11 +448,33 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
refreshTabsAreOverflowing()
})
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
return (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
@@ -782,7 +805,6 @@ function TabNavItem(props: {
>
<Show when={session.latest}>
{(session) => {
console.log({ session: session() })
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
@@ -851,6 +873,59 @@ function ProjectTabAvatar(props: {
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
data-active={props.active}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { updaterAction } from "./updater-action"
describe("updaterAction", () => {
test("disables update actions when the platform has no updater", () => {
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
})
test("projects updater transitions into one settings action", () => {
expect(updaterAction({ status: "idle" })).toEqual({
label: "settings.updates.action.checkNow",
run: "check",
})
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
label: "settings.updates.action.downloading",
})
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
label: "toast.update.action.installRestart",
run: "install",
})
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
label: "settings.updates.action.installing",
})
})
})
@@ -0,0 +1,51 @@
import { createMemo } from "solid-js"
import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
switch (state.status) {
case "checking":
return { label: "settings.updates.action.checking" as const }
case "downloading":
return { label: "settings.updates.action.downloading" as const }
case "ready":
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
case "installing":
return { label: "settings.updates.action.installing" as const }
case "disabled":
return { label: "settings.updates.action.checkNow" as const }
default:
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
}
}
export function useUpdaterAction() {
const platform = usePlatform()
const language = useLanguage()
const action = createMemo(() => updaterAction(platform.updater?.state()))
return {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run !== "check") return
const state = await platform.updater?.check()
if (state?.status === "up-to-date") {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
}
if (state?.status === "error") {
showToast({ title: language.t("common.requestFailed"), description: state.message })
}
},
}
}
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
@@ -609,6 +609,9 @@ export const createDirSyncContext = (
)
},
},
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute,
get directory() {
return current()[0].path.directory
@@ -133,6 +133,7 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/project")
expect(store.status).toBe("loading")
expect(store.limit).toBe(5)
expect(bootstraps).toEqual(["/project"])
} finally {
dispose()
@@ -134,6 +134,27 @@ describe("applyGlobalEvent", () => {
})
describe("applyDirectoryEvent", () => {
test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
retainedLimit: 3,
})
expect(store.session).toHaveLength(3)
})
test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
@@ -99,8 +99,10 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
}) {
const event = input.event
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
case "server.instance.disposed": {
input.push(input.directory)
@@ -115,7 +117,7 @@ export function applyDirectoryEvent(input: {
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
@@ -145,7 +147,7 @@ export function applyDirectoryEvent(input: {
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import { toggleMcp } from "./mcp"
describe("toggleMcp", () => {
test("runs the status action before refreshing the owning query", async () => {
const calls: string[] = []
const input = (status: "connected" | "needs_auth" | "disabled") => ({
status,
connect: async () => {
calls.push("connect")
},
disconnect: async () => {
calls.push("disconnect")
},
authenticate: async () => {
calls.push("authenticate")
},
refresh: async () => {
calls.push("refresh")
},
})
await toggleMcp(input("connected"))
expect(calls).toEqual(["disconnect", "refresh"])
calls.length = 0
await toggleMcp(input("needs_auth"))
expect(calls).toEqual(["authenticate", "refresh"])
calls.length = 0
await toggleMcp(input("disabled"))
expect(calls).toEqual(["connect", "refresh"])
})
})
@@ -0,0 +1,18 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
export async function toggleMcp(input: {
status: McpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
refresh: () => Promise<void>
}) {
await {
connected: input.disconnect,
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
+9 -2
View File
@@ -77,6 +77,7 @@ export type ReviewDiffStyle = "unified" | "split"
export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
@@ -120,10 +121,16 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
const currentRoute = (pathname: string): LayoutRoute => {
const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
if (!draftID) return { type: "home" }
return { type: "draft", draftID }
}
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
@@ -145,7 +152,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname)
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
return { ...value, server: server.key }
})
+19
View File
@@ -0,0 +1,19 @@
import { useMutation } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useSync } from "@/context/sync"
import { showToast } from "@/utils/toast"
export function useMcpToggle() {
const sync = useSync()
const language = useLanguage()
return useMutation(() => ({
mutationFn: sync.mcp.toggle,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
}))
}
+30 -25
View File
@@ -3,12 +3,19 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type OpenAttachmentPickerOptions = {
title?: string
multiple?: boolean
accept?: string[]
extensions?: string[]
defaultPath?: string
}
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
@@ -20,13 +27,7 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
/** Desktop OS (Tauri only) */
os?: DesktopOS
type PlatformBase = {
/** App version */
version?: string
@@ -48,23 +49,20 @@ export type Platform = {
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
/** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?(
opts: OpenAttachmentPickerOptions,
onFile: (file: File) => Promise<unknown>,
): Promise<void>
/** Open native file picker dialog (Tauri only) */
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Application-global desktop updater */
updater?: UpdaterPlatform
/** Fetch override */
fetch?: typeof fetch
@@ -75,11 +73,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
@@ -115,6 +110,16 @@ export type Platform = {
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}
export type Platform = PlatformBase &
(
| { platform: "web"; os?: never }
| {
platform: "desktop"
os?: DesktopOS
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
}
)
export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+21 -12
View File
@@ -1,6 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -153,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
type Scope = { draftID: string } | { dir: string; id?: string }
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
type PromptCacheEntry = {
@@ -163,11 +165,15 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
promptTarget(serverScope, scope),
createStore<{
prompt: Prompt
cursor?: number
@@ -231,6 +237,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false,
init: () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>()
@@ -254,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const load = (scope: Scope) => {
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -265,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK.scope, dir, id),
value: createPromptSession(serverSDK.scope, scope),
dispose,
}),
owner,
@@ -276,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
return {
ready: () => session().ready,
+34 -6
View File
@@ -36,6 +36,7 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
type GlobalStore = {
ready: boolean
@@ -247,17 +248,21 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
},
})
async function loadSessions(directory: string) {
async function loadSessions(directory: string, options?: { limit?: number }) {
const key = directoryKey(directory)
const pending = sessionLoads.get(key)
if (pending) return pending
if (pending) {
await pending
return loadSessions(directory, options)
}
children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key)
if (meta && meta.limit >= store.limit) {
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
if (meta && meta.limit >= retainedLimit) {
const next = trimSessions(store.session, {
limit: store.limit,
limit: retainedLimit,
permission: store.permission,
})
if (next.length !== store.session.length) {
@@ -268,7 +273,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return
}
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
...queryOptionsApi.sessions(key),
@@ -283,7 +288,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
@@ -400,6 +405,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
setStore,
push: queue.push,
setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
@@ -476,6 +482,28 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
todo: {
set: setSessionTodo,
},
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({
status,
connect: async () => {
await sdk.mcp.connect({ name })
},
disconnect: async () => {
await sdk.mcp.disconnect({ name })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
},
})
},
},
}
}
+41 -1
View File
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
import {
createServerProjects,
migrateCanonicalLocalServerState,
nextServerAfterRemoval,
resolveServerList,
ServerConnection,
} from "./server"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => {
@@ -55,6 +61,40 @@ describe("resolveServerList", () => {
})
})
test("treats WSL sidecars as remote server connections", () => {
expect(
ServerConnection.local({
type: "sidecar",
variant: "wsl",
distro: "Debian",
http: { url: "http://127.0.0.1:4097" },
}),
).toBe(false)
expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe(
true,
)
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true)
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
})
test("active server removal falls back across built-in and persisted servers", () => {
const local = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } as const
const debian = {
type: "sidecar",
variant: "wsl",
distro: "Debian",
http: { url: "http://127.0.0.1:4097" },
} as const
expect(
nextServerAfterRemoval(
[local, debian],
ServerConnection.Key.make("wsl:Debian"),
ServerConnection.Key.make("sidecar"),
),
).toBe(ServerConnection.Key.make("sidecar"))
})
describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
+18 -9
View File
@@ -145,7 +145,7 @@ export function resolveServerList(input: {
}
export namespace ServerConnection {
type Base = { displayName?: string }
type Base = { displayName?: string; label?: string }
export type HttpBase = {
url: string
@@ -202,6 +202,20 @@ export namespace ServerConnection {
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) =>
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
}
export function nextServerAfterRemoval(
servers: ServerConnection.Any[],
removed: ServerConnection.Key,
fallback: ServerConnection.Key,
) {
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
return next ? ServerConnection.key(next) : fallback
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
@@ -255,13 +269,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
function remove(key: ServerConnection.Key) {
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
const list = store.list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
if (state.active === key) setState("active", next)
})
}
@@ -280,10 +292,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
const isLocal = createMemo(() => ServerConnection.local(current()))
return {
ready: isReady,
-12
View File
@@ -35,9 +35,6 @@ export interface Settings {
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
}
appearance: {
fontSize: number
mono: string
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
},
appearance: {
fontSize: 14,
mono: "",
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
+82 -6
View File
@@ -1,9 +1,13 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
export type SessionTab = {
@@ -13,16 +17,37 @@ export type SessionTab = {
sessionId: string
}
export type Tab = SessionTab
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
}
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export type Tab = SessionTab | DraftTab
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const server = useServer()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
@@ -40,9 +65,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const closing = new Set<string>()
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
createEffect(() => {
if (!ready()) return
const servers = new Set(server.list.map(ServerConnection.key))
@@ -73,10 +103,48 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid()
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
// replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
})
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
@@ -88,9 +156,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
@@ -100,7 +171,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
: undefined
const currentIndex = currentHref
? tabs.findIndex(
@@ -9,6 +9,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+55
View File
@@ -349,6 +349,59 @@ export const dict = {
"dialog.server.menu.delete": "Delete",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"wsl.server.add": "Add WSL server",
"wsl.server.addShort": "Add WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL server",
"wsl.server.retryStart": "Retry start",
"wsl.server.updating": "Updating...",
"wsl.onboarding.step.distro": "Choose distro",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Checking WSL...",
"wsl.onboarding.restartRequired": "Windows needs a restart to finish installing WSL.",
"wsl.onboarding.ready": "WSL is ready.",
"wsl.onboarding.required": "WSL is required to continue.",
"wsl.onboarding.checkingDistros": "Checking distros...",
"wsl.onboarding.installingDistro": "Installing {{distro}}...",
"wsl.onboarding.checkingDistro": "Checking {{distro}}...",
"wsl.onboarding.listingDistros": "Listing distros...",
"wsl.onboarding.distroReady": "{{distro}} is ready.",
"wsl.onboarding.distroNotInstalled": "{{distro}} is not installed yet.",
"wsl.onboarding.openDistroOnce": "Open {{distro}} once to finish setup.",
"wsl.onboarding.finishingDistro": "Finishing setup for {{distro}}.",
"wsl.onboarding.pickDistro": "Pick a distro or install one below.",
"wsl.onboarding.checkingOpencode": "Checking OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Checking OpenCode in {{distro}}...",
"wsl.onboarding.updatingOpencode": "Updating OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Updating OpenCode in {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Update OpenCode in {{distro}}.",
"wsl.onboarding.updateOpencode": "Update OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode is ready in {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode is ready.",
"wsl.onboarding.installOpencodeIn": "Install OpenCode in {{distro}}.",
"wsl.onboarding.installOpencode": "Install OpenCode",
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
"wsl.onboarding.loading": "Loading...",
"wsl.onboarding.installWsl": "Install WSL",
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
"wsl.onboarding.next": "Next",
"wsl.onboarding.refresh": "Refresh",
"wsl.onboarding.allDistrosAdded": "All installed distros are already added.",
"wsl.onboarding.noDistros": "No distros detected yet.",
"wsl.onboarding.install": "Install",
"wsl.onboarding.installing": "Installing...",
"wsl.onboarding.installDistro": "Install distro",
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
"wsl.onboarding.openTerminal": "Open terminal",
"wsl.onboarding.path": "Path: {{path}}",
"wsl.onboarding.notFound": "not found",
"wsl.onboarding.version": "Version: {{version}}",
"wsl.onboarding.unknown": "unknown",
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
"wsl.onboarding.adding": "Adding...",
"server.row.noUsername": "no username",
"dialog.project.edit.title": "Edit project",
@@ -813,6 +866,8 @@ export const dict = {
"settings.updates.row.check.description": "Manually check for updates and install if available",
"settings.updates.action.checkNow": "Check now",
"settings.updates.action.checking": "Checking...",
"settings.updates.action.downloading": "Downloading...",
"settings.updates.action.installing": "Installing...",
"settings.updates.toast.latest.title": "You're up to date",
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
"sound.option.none": "None",
+16
View File
@@ -2,6 +2,22 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { useWslServers } from "./wsl/context"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type UpdaterPlatform, type UpdaterState } from "./updater"
export {
type WslDistroProbe,
type WslInstalledDistro,
type WslJob,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslRuntimeCheck,
type WslServerConfig,
type WslServerItem,
type WslServerRuntime,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
+3 -1
View File
@@ -10,7 +10,7 @@ import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
const location = useLocation()
const navigate = useNavigate()
const params = useParams()
@@ -18,6 +18,8 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
+24 -26
View File
@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const formattedError = () => formatError(props.error, language.t)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
actionError: undefined as string | undefined,
})
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)
await platform
.checkUpdate()
.then((result) => {
setStore("actionError", undefined)
if (result.updateAvailable && result.version) setStore("version", result.version)
})
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
.finally(() => {
setStore("checking", false)
})
const state = await platform.updater?.check()
setStore("actionError", state?.status === "error" ? state.message : undefined)
}
async function installUpdate() {
if (!platform.updateAndRestart) return
await platform
.updateAndRestart()
await platform.updater
?.install()
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
}
const updateVersion = () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
}
async function exportDebugLogs() {
const exportLogs = platform.exportDebugLogs
if (!exportLogs) return
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
)
}}
</Show>
<Show when={platform.checkUpdate}>
<Show when={platform.updater}>
<Show
when={store.version}
when={updateVersion()}
fallback={
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
{store.checking
<Button
size="large"
variant="ghost"
onClick={checkForUpdates}
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
>
{platform.updater?.state().status === "checking"
? language.t("error.page.action.checking")
: language.t("error.page.action.checkUpdates")}
</Button>
}
>
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
</Button>
{(version) => (
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: version() })}
</Button>
)}
</Show>
</Show>
</div>
+167 -187
View File
@@ -6,12 +6,12 @@ import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -19,41 +19,42 @@ import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useDirectoryPicker } from "@/components/directory-picker"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import {
closeHomeProject,
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
homeSessionServerStatus,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
const HOME_SESSION_LIMIT = 15
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
@@ -62,8 +63,6 @@ type HomeSessionRecord = {
projectName: string
}
type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -110,53 +109,6 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
function createHomeSessionStatus(input: {
record: () => HomeSessionRecord
sync: () => HomeSessionSync
activeServer: () => boolean
}) {
const notification = useNotification()
const permission = usePermission()
const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
const unseenCount = createMemo(() =>
input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0,
)
const hasError = createMemo(
() => input.activeServer() && notification.session.unseenHasError(input.record().session.id),
)
const hasPermissions = createMemo(
() =>
input.activeServer() &&
!!sessionPermissionRequest(
sessionStore().session,
sessionStore().permission,
input.record().session.id,
(item) => {
return !permission.autoResponds(item, input.record().session.directory)
},
),
)
const serverStatus = createMemo(() =>
homeSessionServerStatus(input.activeServer(), () => ({
working: sessionStore().session_working(input.record().session.id),
tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
})),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return serverStatus().working
})
const tint = createMemo(() => serverStatus().tint)
return {
unseenCount,
hasError,
hasPermissions,
isWorking,
tint,
show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
}
}
function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
@@ -174,6 +126,7 @@ function HomeDesign() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
@@ -215,7 +168,11 @@ function HomeDesign() {
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
),
)
return null
},
}))
@@ -359,26 +316,19 @@ function HomeDesign() {
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject(conn: ServerConnection.Any) {
function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
if (platform.openDirectoryPickerDialog && server.isLocal) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
return
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
function openSettings() {
@@ -388,7 +338,7 @@ function HomeDesign() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 bg-v2-background-bg-base self-stretch flex-1">
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
@@ -414,14 +364,17 @@ function HomeDesign() {
language={language}
/>
<section class="min-w-0 flex-1 flex flex-col pt-12" aria-label={language.t("sidebar.project.recentSessions")}>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
sync={focusedSync()}
server={state.selection.server}
activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
@@ -432,7 +385,7 @@ function HomeDesign() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ScrollView class="mt-3 min-h-0 flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show
when={!sessionLoad.isLoading}
@@ -461,7 +414,7 @@ function HomeDesign() {
{(record) => (
<HomeSessionRow
record={record}
sync={focusedSync()}
server={state.selection.server}
activeServer={state.selection.server === server.key}
openSession={openSession}
/>
@@ -474,7 +427,7 @@ function HomeDesign() {
</Show>
</Show>
</div>
</div>
</ScrollView>
</section>
</div>
</div>
@@ -497,6 +450,8 @@ function HomeProjectColumn(props: {
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
@@ -524,29 +479,17 @@ function HomeProjectColumn(props: {
const serverCtx = global.createServerCtx(item)
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected.server === key && !props.selected.directory ? "" : undefined}
disabled={!healthy()}
onClick={() => props.focusServer(item)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={global.servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
</button>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(item)}
/>
</div>
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
healthy={healthy()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
language={props.language}
/>
<Show when={healthy()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
@@ -556,7 +499,7 @@ function HomeProjectColumn(props: {
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -578,6 +521,65 @@ function HomeProjectColumn(props: {
)
}
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
healthy: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
focusServer: (server: ServerConnection.Any) => void
chooseProject: (server: ServerConnection.Any) => void
openEdit: (server: ServerConnection.Http) => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!props.healthy}
onClick={() => props.focusServer(props.server)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{label()}
</span>
)}
</Show>
</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<ServerRowMenu
server={props.server}
controller={props.controller}
onEdit={props.openEdit}
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
/>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(props.server)}
/>
</div>
</div>
)
}
function HomeProjectList(props: {
server: ServerConnection.Any
projects: LocalProject[]
@@ -705,13 +707,50 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
const directory = () => props.session.directory
const sessionId = () => props.session.id
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
server: ServerConnection.Key
activeServer: boolean
}) {
const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
return (
<div class="relative shrink-0">
<Show when={hasOpenTab()}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 12px)" }}
/>
</Show>
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
</div>
)
}
function HomeSessionSearch(props: {
value: string
placeholder: string
open: boolean
loading: boolean
results: HomeSessionRecord[]
sync: HomeSessionSync
server: ServerConnection.Key
activeServer: boolean
noResultsLabel: string
bindFocus: (focus: () => void) => void
@@ -827,7 +866,7 @@ function HomeSessionSearch(props: {
{(record) => (
<HomeSessionSearchResultRow
record={record}
sync={props.sync}
server={props.server}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
@@ -846,7 +885,7 @@ function HomeSessionSearch(props: {
<label
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
classList={{
"bg-v2-background-bg-deep focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
!props.open,
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
}}
@@ -913,17 +952,12 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
server: ServerConnection.Key
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
}) {
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const key = () => homeSessionSearchKey(props.record)
@@ -943,34 +977,12 @@ function HomeSessionSearchResultRow(props: {
onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)}
>
<Show
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
@@ -1010,15 +1022,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
function HomeSessionRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
server: ServerConnection.Key
activeServer: boolean
openSession: (session: Session) => void
}) {
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
return (
@@ -1028,34 +1035,12 @@ function HomeSessionRow(props: {
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
>
<Show
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
@@ -1111,6 +1096,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
@@ -1138,7 +1124,7 @@ function LegacyHome() {
navigate(`/${base64Encode(directory)}`)
}
async function chooseProject() {
function chooseProject() {
const s = server.current
if (!s) return
@@ -1152,18 +1138,12 @@ function LegacyHome() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
() => resolve(null),
)
}
pickDirectory({
server: s,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
+17 -37
View File
@@ -14,7 +14,6 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -64,7 +63,9 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
@@ -90,7 +91,6 @@ import {
} from "./layout/sidebar-workspace"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
import { runUpdateAndRestart } from "./layout/update"
export default function Layout(props: ParentProps) {
const serverSDK = useServerSDK()
@@ -119,6 +119,7 @@ export default function Layout(props: ParentProps) {
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const server = useServer()
const notification = useNotification()
@@ -168,28 +169,15 @@ export default function Layout(props: ParentProps) {
peeked: false,
})
const [update, setUpdate] = createStore({
installing: false,
})
const updateQuery = useQuery(() => ({
queryKey: ["desktop", "update"] as const,
enabled: () =>
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
}))
const updateVersion = () => {
if (!settings.ready()) return
if (!settings.updates.startup()) return
if (!updateQuery.data?.updateAvailable) return
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
}
const installUpdate = () => void platform.updater?.install()
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => update.installing,
installing: () => platform.updater?.state().status === "installing",
install: installUpdate,
}
@@ -1472,7 +1460,7 @@ export default function Layout(props: ParentProps) {
})
}
async function chooseProject() {
function chooseProject() {
const conn = server.current
if (!conn) return
function resolve(result: string | string[] | null) {
@@ -1486,22 +1474,12 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
})
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
@@ -2387,6 +2365,7 @@ export default function Layout(props: ParentProps) {
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
@@ -2540,6 +2519,7 @@ export default function Layout(props: ParentProps) {
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
</Show>
@@ -1,19 +0,0 @@
import { describe, expect, test } from "bun:test"
import { runUpdateAndRestart } from "./update"
describe("runUpdateAndRestart", () => {
test("clears the installing state when restart resolves without exiting", async () => {
const states: boolean[] = []
await new Promise<void>((resolve) => {
runUpdateAndRestart(
async () => {},
(installing) => {
states.push(installing)
if (states.length === 2) resolve()
},
)
})
expect(states).toEqual([true, false])
})
})
-10
View File
@@ -1,10 +0,0 @@
export function runUpdateAndRestart(
updateAndRestart: (() => Promise<void>) | undefined,
setInstalling: (installing: boolean) => void,
) {
if (!updateAndRestart) return
setInstalling(true)
void updateAndRestart()
.catch(() => undefined)
.finally(() => setInstalling(false))
}
+78
View File
@@ -0,0 +1,78 @@
import { createEffect, createMemo, onMount, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useSearchParams } from "@solidjs/router"
import { NewSessionDesignView } from "@/components/session"
import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
* composer for a brand-new session — no terminal, review pane, file tree, or message
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
*/
export default function NewSessionPage() {
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
let inputRef: HTMLDivElement | undefined
const composer = createSessionComposerState()
const [store, setStore] = createStore({
worktree: "main",
})
const newSessionWorktree = createMemo(() => {
if (store.worktree === "create") return "create"
const project = sync.project
if (project && sdk.directory !== project.worktree) return sdk.directory
return "main"
})
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
onMount(() => {
requestAnimationFrame(() => inputRef?.focus())
})
return (
<div class="relative size-full overflow-hidden flex flex-col">
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView>
<SessionComposerRegion
state={composer}
ready
centered={false}
placement="inline"
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
onResponseSubmit={() => {}}
setPromptDockRef={() => {}}
/>
</NewSessionDesignView>
</div>
</div>
</div>
</div>
)
}
+74 -67
View File
@@ -31,13 +31,14 @@ import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
@@ -51,6 +52,7 @@ import {
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
@@ -61,7 +63,6 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
@@ -194,6 +195,7 @@ export default function Page() {
const sdk = useSDK()
const serverSDK = useServerSDK()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
@@ -268,10 +270,16 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
@@ -1745,81 +1753,80 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
style={{
width: sessionPanelWidth(),
}}
>
<div
class="flex-1 min-h-0 overflow-hidden"
classList={{
"rounded-[10px]": settings.general.newLayoutDesigns(),
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
<ResizeHandle
@@ -8,8 +8,17 @@ import {
focusTerminalById,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "./helpers"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled v2 file tree", () => {
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
})
})
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
const calls: string[] = []
@@ -20,6 +20,10 @@ type TabsInput = {
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) {
return input.opened && (!input.desktopV2 || input.showFileTree)
}
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
@@ -1,14 +0,0 @@
import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
})
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,6 +1,2 @@
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
}
@@ -24,7 +24,13 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import {
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -59,10 +65,18 @@ export function SessionSidePanel(props: {
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const shown = createMemo(() => !desktopV2() || settings.general.showFileTree())
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: desktopV2(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
@@ -547,6 +547,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -554,6 +555,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(-1),
}),
]
+17
View File
@@ -0,0 +1,17 @@
import type { Accessor } from "solid-js"
export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
| { status: "downloading"; version: string; percent?: number }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
| { status: "error"; message: string }
export type UpdaterPlatform = {
state: Accessor<UpdaterState>
check(): Promise<UpdaterState>
install(): Promise<void>
}
+18
View File
@@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
})
test("draft target isolates storage per draft and namespaces keys", () => {
const a = Persist.draft("draft-a", "prompt")
const b = Persist.draft("draft-b", "prompt")
expect(a.key).toBe("draft:prompt")
expect(a.storage).not.toBe(b.storage)
expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
})
test("removes draft storage when removing persisted target", () => {
const target = Persist.draft("draft-a", "prompt")
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
removePersisted(target)
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
})
test("server workspace target preserves local storage and isolates remote storage", () => {
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
+15
View File
@@ -341,6 +341,12 @@ function workspaceStorage(dir: string) {
return `opencode.workspace.${head}.${sum}.dat`
}
function draftStorage(draftID: string) {
const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(draftID) ?? "0"
return `opencode.draft.${head}.${sum}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
@@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage {
}
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
}
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
@@ -462,6 +474,9 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
},
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
if (scope === ServerScope.local) return Persist.global(key, legacy)
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
+36
View File
@@ -0,0 +1,36 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { WslServersState } from "./types"
import { usePlatform } from "../context/platform"
const wslServersQueryKey = ["platform", "wslServers"] as const
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
name: "WslServers",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => {
const api = platform.wslServers
return queryOptions<WslServersState>({
queryKey: wslServersQueryKey,
queryFn: () => api!.getState(),
enabled: !!api,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
createEffect(() => {
const api = platform.wslServers
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(wslServersQueryKey, event.state)
})
onCleanup(off)
})
return query as typeof query & { readonly data: WslServersState | undefined }
},
})
+623
View File
@@ -0,0 +1,623 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "./context"
import { enterWslOpencodeStep } from "./settings-model"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const selectedDistro = createMemo(() => {
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
return store.selectedDistro
}
const distro = defaultInstalledDistro()
if (distro && !existingServerDistros().has(distro.name)) return distro.name
return null
})
const selectedProbe = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const opencodeCheck = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = selectedDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
return language.t("wsl.onboarding.openDistroOnce", { distro })
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = selectedDistro()
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
return state.runtime?.error ?? language.t("wsl.onboarding.required")
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingDistros")
const distro = selectedDistro()
if (state.job?.kind === "install-distro")
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
if (state.job?.kind === "probe-distro")
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady())
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
return language.t("wsl.onboarding.pickDistro")
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingOpencode")
const distro = selectedDistro()
if (state.job?.kind === "install-opencode") {
return distro
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
: language.t("wsl.onboarding.updatingOpencode")
}
if (state.job?.kind === "probe-opencode") {
return distro
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
: language.t("wsl.onboarding.checkingOpencode")
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
: language.t("wsl.onboarding.updateOpencode")
}
if (opencodeReady()) {
return distro
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
: language.t("wsl.onboarding.opencodeReady")
}
return distro
? language.t("wsl.onboarding.installOpencodeIn", { distro })
: language.t("wsl.onboarding.chooseDistroFirst")
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = selectedDistro()
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const openOpencodeStep = () => {
const distro = selectedDistro()
if (!distro) return
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
}
const finish = async () => {
const distro = selectedDistro()
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title:
step === "wsl"
? language.t("wsl.server.label")
: step === "distro"
? language.t("wsl.onboarding.step.distro")
: language.t("wsl.onboarding.step.opencode"),
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return language.t("wsl.onboarding.loadFailed")
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show
when={!wslServers.isPending}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
>
<Show
when={!wslServers.isError}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
{language.t("wsl.onboarding.installWsl")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3">
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.windowsRestartRequired")}
</div>
</div>
</Show>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !wslReady()}
onClick={() => setStore("step", "distro")}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? language.t("wsl.onboarding.allDistrosAdded")
: current()?.runtime?.available
? language.t("wsl.onboarding.noDistros")
: language.t("wsl.onboarding.checkingDistros")}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro()
? language.t("wsl.onboarding.installing")
: language.t("wsl.onboarding.install")}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label={language.t("wsl.onboarding.installDistro")}
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => installTarget()?.name === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.wsl2Required")}
</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.toolsRequired")}
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
{language.t("wsl.onboarding.openTerminal")}
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedDistro() || !distroReady()}
onClick={openOpencodeStep}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
<div class="flex items-center gap-2">
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath
? language.t("wsl.onboarding.updateOpencode")
: language.t("wsl.onboarding.installOpencode")}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.path", {
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
})}
</div>
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.version", {
version: check().version ?? language.t("wsl.onboarding.unknown"),
})}
<Show when={check().expectedVersion}>
{(expected) => (
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
)}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.versionMismatch")}
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
describe("WSL server settings presentation", () => {
test("retries only settled unsuccessful runtimes", () => {
expect(wslRuntimeRetryable({ kind: "starting" })).toBe(false)
expect(wslRuntimeRetryable({ kind: "ready", url: "http://127.0.0.1:4096", username: null, password: null })).toBe(
false,
)
expect(wslRuntimeRetryable({ kind: "failed", message: "boom" })).toBe(true)
expect(wslRuntimeRetryable({ kind: "stopped" })).toBe(true)
})
test("offers install and update only when OpenCode needs attention", () => {
expect(wslOpencodeAction(undefined)).toBeUndefined()
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: null,
version: null,
expectedVersion: "1.2.3",
matchesDesktop: null,
error: null,
}),
).toBe("Install OpenCode")
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: "/usr/local/bin/opencode",
version: "1.2.2",
expectedVersion: "1.2.3",
matchesDesktop: false,
error: null,
}),
).toBe("Update OpenCode")
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: "/usr/local/bin/opencode",
version: "1.2.3",
expectedVersion: "1.2.3",
matchesDesktop: true,
error: null,
}),
).toBeUndefined()
})
test("probes the selected distro before entering the OpenCode step", async () => {
const calls: string[] = []
await enterWslOpencodeStep(
"Debian",
async (distro) => calls.push(distro),
(step) => calls.push(step),
)
expect(calls).toEqual(["Debian", "opencode"])
})
})
+19
View File
@@ -0,0 +1,19 @@
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
runtime.kind === "failed" || runtime.kind === "stopped"
export async function enterWslOpencodeStep(
distro: string,
probe: (distro: string) => Promise<unknown>,
select: (step: "opencode") => void,
) {
await probe(distro)
select("opencode")
}
export function wslOpencodeAction(check?: WslOpencodeCheck) {
if (!check) return
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
}
+163
View File
@@ -0,0 +1,163 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { showToast } from "@/utils/toast"
import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function WslAddServerButton() {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const openAdd = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
</Dialog>
))
}
return (
<Show when={platform.wslServers}>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("wsl.server.addShort")}
</ButtonV2>
</Show>
)
}
export function useFilteredWslServers(filter: Accessor<string>) {
const wsl = useWslServers()
return createMemo(() => {
const servers = wsl.data?.servers ?? []
const query = filter().trim()
if (!query) return servers
return fuzzysort
.go(query, servers, { keys: [(item) => item.config.distro, (item) => item.config.id] })
.map((x) => x.obj)
})
}
export function WslServerSettings(props: {
controller: Controller
servers: ReturnType<typeof useFilteredWslServers>
}) {
const platform = usePlatform()
const language = useLanguage()
const wsl = useWslServers()
const api = platform.wslServers
const request = useMutation(() => ({
mutationFn: (action: () => Promise<unknown>) => action(),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key))
}
return (
<Show when={api}>
<For each={props.servers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const check = () => wsl.data?.opencodeChecks[item.config.distro]
const opencodeAction = () => wslOpencodeAction(check())
const busy = () => wsl.data?.job?.kind === "install-opencode" && wsl.data.job.distro === item.config.distro
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} />
<div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span>
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{language.t("wsl.server.label")}
</span>
</span>
<span class="settings-v2-servers-meta">
<Show when={check()?.version}>{(version) => `v${version()}`}</Show>
</span>
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<Show when={opencodeAction()}>
{(label) => (
<ButtonV2
size="small"
disabled={busy() || request.isPending}
onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))}
>
{busy() ? language.t("wsl.server.updating") : label()}
</ButtonV2>
)}
</Show>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("wsl.server.menu.label")}</MenuV2.GroupLabel>
<Show when={wslRuntimeRetryable(item.runtime)}>
<MenuV2.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
{language.t("wsl.server.retryStart")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => remove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
}}
</For>
</Show>
)
}
+87
View File
@@ -0,0 +1,87 @@
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}
+1
View File
@@ -0,0 +1 @@
preload = ["@opentui/solid/preload"]
+6 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.16.0",
"version": "1.17.4",
"type": "module",
"license": "MIT",
"bin": {
@@ -20,8 +20,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:"
"effect": "catalog:",
"solid-js": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
+21 -1
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
@@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -43,6 +49,12 @@ const targets = singleFlag
})
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@@ -56,8 +68,9 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
entrypoints: ["./src/index.ts", parserWorker],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
@@ -79,6 +92,13 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@@ -0,0 +1,13 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
)
@@ -1,4 +1,6 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
@@ -34,6 +36,8 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
+10 -10
View File
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
const handler = handlers.find((handler) => handler.spec === node.spec)
const spec = handler
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
)
: node.spec
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
return spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
) as ProvidedCommand
}
+1
View File
@@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
+22 -10
View File
@@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
@@ -108,16 +110,21 @@ export const layer = Layer.effect(
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found?.version === InstallationVersion) return found.url
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined)
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
detached: true,
stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* compatible().pipe(
@@ -127,8 +134,13 @@ export const layer = Layer.effect(
)
})
const transport = Effect.fn("cli.daemon.transport")(function* () {
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start())
const connection = yield* transport()
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
})
const status = Effect.fn("cli.daemon.status")(function* () {
@@ -173,7 +185,7 @@ export const layer = Layer.effect(
)
})
return Service.of({ client, start, status, stop, password, register })
return Service.of({ client, transport, start, status, stop, password, register })
}),
)
+36
View File
@@ -0,0 +1,36 @@
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
+2
View File
@@ -2,6 +2,8 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.16.0",
"version": "1.17.4",
"type": "module",
"license": "MIT",
"scripts": {
+9 -14
View File
@@ -13,10 +13,9 @@ import copyLogoSvgLight from "../asset/lander/opencode-logo-light.svg"
import copyLogoSvgDark from "../asset/lander/opencode-logo-dark.svg"
import copyWordmarkSvgLight from "../asset/lander/opencode-wordmark-light.svg"
import copyWordmarkSvgDark from "../asset/lander/opencode-wordmark-dark.svg"
import { A, createAsync, useNavigate } from "@solidjs/router"
import { A, useNavigate } from "@solidjs/router"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
import { useI18n } from "~/context/i18n"
@@ -40,16 +39,6 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
const navigate = useNavigate()
const i18n = useI18n()
const language = useLanguage()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 0,
}).format(githubData()?.stars)
: config.github.starsFormatted.compact,
)
const [store, setStore] = createStore({
mobileMenuOpen: false,
@@ -155,12 +144,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
<ul>
<li>
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
{i18n.t("nav.github")} <span>[{starCount()}]</span>
{i18n.t("nav.github")}
</a>
</li>
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<li>
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
</li>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</li>
@@ -252,12 +244,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
</li>
<li>
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
{i18n.t("nav.github")} <span>[{starCount()}]</span>
{i18n.t("nav.github")}
</a>
</li>
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<li>
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
</li>
<Show when={!props.zen}>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
+5 -4
View File
@@ -248,8 +248,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
"go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
"go.hero.body":
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
@@ -298,7 +299,7 @@ export const dict = {
"go.problem.item2": "حدود سخية ووصول موثوق",
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
"go.problem.item4":
"يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
"يتضمن GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash",
"go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا",
@@ -322,7 +323,7 @@ export const dict = {
"go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.",
"go.faq.q3": "هل Go هو نفسه Zen؟",
"go.faq.a3":
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.faq.q4": "كم تكلفة Go؟",
"go.faq.a4.p1.beforePricing": "تكلفة Go",
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
@@ -345,7 +346,7 @@ export const dict = {
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9":
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
+5 -4
View File
@@ -252,8 +252,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
"go.hero.body":
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
@@ -303,7 +304,7 @@ export const dict = {
"go.problem.item2": "Limites generosos e acesso confiável",
"go.problem.item3": "Feito para o maior número possível de programadores",
"go.problem.item4":
"Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"Inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash",
"go.how.title": "Como o Go funciona",
"go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
@@ -329,7 +330,7 @@ export const dict = {
"go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.",
"go.faq.q3": "O Go é o mesmo que o Zen?",
"go.faq.a3":
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.faq.q4": "Quanto custa o Go?",
"go.faq.a4.p1.beforePricing": "O Go custa",
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
@@ -353,7 +354,7 @@ export const dict = {
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9":
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
+5 -4
View File
@@ -250,8 +250,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
"go.hero.body":
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
@@ -300,7 +301,7 @@ export const dict = {
"go.problem.item2": "Generøse grænser og pålidelig adgang",
"go.problem.item3": "Bygget til så mange programmører som muligt",
"go.problem.item4":
"Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"Inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash",
"go.how.title": "Hvordan Go virker",
"go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
@@ -326,7 +327,7 @@ export const dict = {
"go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.faq.q4": "Hvad koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -349,7 +350,7 @@ export const dict = {
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
+5 -4
View File
@@ -252,8 +252,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
"go.hero.body":
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
@@ -302,7 +303,7 @@ export const dict = {
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
"go.problem.item4":
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
"Beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash",
"go.how.title": "Wie Go funktioniert",
"go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
@@ -328,7 +329,7 @@ export const dict = {
"go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.",
"go.faq.q3": "Ist Go dasselbe wie Zen?",
"go.faq.a3":
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.faq.q4": "Wie viel kostet Go?",
"go.faq.a4.p1.beforePricing": "Go kostet",
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
@@ -352,7 +353,7 @@ export const dict = {
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9":
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
+6 -4
View File
@@ -1,6 +1,7 @@
export const dict = {
"nav.github": "GitHub",
"nav.docs": "Docs",
"nav.data": "Data",
"nav.changelog": "Changelog",
"nav.discord": "Discord",
"nav.x": "X",
@@ -247,8 +248,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.hero.title": "Low cost coding models for everyone",
"go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
@@ -296,7 +298,7 @@ export const dict = {
"go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4":
"Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
"Includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash",
"go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
@@ -321,7 +323,7 @@ export const dict = {
"go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3":
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$5 first month",
@@ -345,7 +347,7 @@ export const dict = {
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
+5 -4
View File
@@ -253,8 +253,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "siguientes excepciones",
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
"go.hero.body":
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
@@ -304,7 +305,7 @@ export const dict = {
"go.problem.item2": "Límites generosos y acceso fiable",
"go.problem.item3": "Creado para tantos programadores como sea posible",
"go.problem.item4":
"Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
"Incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash",
"go.how.title": "Cómo funciona Go",
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
"go.how.step1.title": "Crear una cuenta",
@@ -329,7 +330,7 @@ export const dict = {
"go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.",
"go.faq.q3": "¿Es Go lo mismo que Zen?",
"go.faq.a3":
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.faq.q4": "¿Cuánto cuesta Go?",
"go.faq.a4.p1.beforePricing": "Go cuesta",
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
@@ -353,7 +354,7 @@ export const dict = {
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
"go.faq.a9":
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",

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