Compare commits

...

367 Commits

Author SHA1 Message Date
James Long b696a681cb run in durable object 2026-06-12 22:50:54 -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
Shoubhit Dash 0ee7cfa1fe fix(core): recover corrupted models cache (#30947) 2026-06-05 18:07:09 +05:30
weiconghe a261b55e43 fix: use mapError instead of orDie for context snapshot decoding (#30905)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-05 18:07:00 +05:30
Shoubhit Dash a136caa1b3 chore: rm fuzzy search on references (#30931) 2026-06-05 16:57:39 +05:30
Shoubhit Dash 7a4d18390a feat(tui): add diff hunk navigation (#30935) 2026-06-05 16:56:53 +05:30
opencode-agent[bot] edbe2280a5 chore: generate 2026-06-05 11:23:14 +00:00
Jack d721fc0a49 docs: update Go Qwen tiered pricing (#30936) 2026-06-05 19:21:46 +08:00
pcadena-lila dc985ff881 fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) 2026-06-05 09:53:41 +00:00
opencode-agent[bot] a468680965 chore: generate 2026-06-05 09:48:43 +00:00
Simon Klee 0c0d193474 opencode/run: refresh themes after terminal reloads (#30917) 2026-06-05 09:47:17 +00:00
Simon Klee b278e49e96 tui: guard path formatting inputs (#30469)
Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895
2026-06-05 09:45:03 +00:00
Brendan Allan c613c33787 fix(app): tab overflow (#30894) 2026-06-05 07:56:44 +00:00
Brendan Allan 9431356715 fix(app): handle tab overflow and scrolling in titlebar (#30886) 2026-06-05 15:42:39 +08:00
opencode-agent[bot] f6197cefe1 chore: generate 2026-06-05 06:31:37 +00:00
Luke Parker 7f33576f46 feat(app): improve desktop multi-server support (#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-05 06:30:02 +00:00
opencode-agent[bot] 7ae856a9e9 chore: generate 2026-06-05 05:31:34 +00:00
Dax b01eb22b2c fix(cli): harden daemon lifecycle (#30844) 2026-06-05 01:30:19 -04:00
opencode-agent[bot] 3cf1cef7fe fix(tui): route permission replies to session directory (#30851)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-05 00:56:06 -04:00
Aiden Cline b375890745 fix(tui): prioritize models slash autocomplete (#30848) 2026-06-04 23:28:43 -05:00
opencode-agent[bot] 0a36433062 chore: generate 2026-06-05 03:42:49 +00:00
Brendan Allan 5426478e46 fix(app): improve tab handling (#30669) 2026-06-05 11:41:30 +08:00
Kit Langton 3003867c25 feat(tui): allow backgrounding synchronous subagents (#30488) 2026-06-04 23:40:52 -04:00
opencode-agent[bot] 8c0edca175 chore: generate 2026-06-05 03:28:04 +00:00
Kit Langton 1af8dafd3e feat(core): persist v2 session context epochs (#30789) 2026-06-04 23:26:43 -04:00
opencode-agent[bot] c47cb28781 chore: generate 2026-06-05 03:21:15 +00:00
Kamesh Sampath 2859ce6e73 feat(core): add Snowflake Cortex provider (#29901)
Co-authored-by: Cortex Code <noreply@snowflake.com>
2026-06-04 22:19:55 -05:00
opencode-agent[bot] 39babcbb53 chore: generate 2026-06-05 03:14:18 +00:00
Kit Langton 64dc6d39ab feat(core): attach global native tools (#30832) 2026-06-04 23:12:17 -04:00
opencode 17ba5539e7 sync release versions for v1.16.0 2026-06-05 03:08:13 +00:00
opencode-agent[bot] 9211ef7e95 chore: generate 2026-06-05 00:54:49 +00:00
Aarav Sareen 605ae48c6f feat(app): color themes (#30824)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-05 00:53:36 +00:00
opencode-agent[bot] 75557000de chore: generate 2026-06-05 00:50:58 +00:00
Kit Langton 773d33e282 feat(core): add public native API (#30828) 2026-06-04 20:49:43 -04:00
Adam 46e9863589 fix(stats): sort metric charts by top usage 2026-06-04 19:36:29 -05:00
Adam 47f3d8b709 feat(stats): refresh stats routes and homepage (#30419) 2026-06-05 00:32:49 +00:00
opencode-agent[bot] 134f4136da chore: generate 2026-06-04 23:58:11 +00:00
Kit Langton f011d77128 fix(llm): normalize OpenAI function tool schemas 2026-06-04 19:56:31 -04:00
opencode-agent[bot] ab5a12d916 chore: generate 2026-06-04 23:26:09 +00:00
Kit Langton 76ecf2e58c refactor(core): make v2 session inputs event sourced (#30785) 2026-06-04 19:24:30 -04:00
Dax Raad 057958c933 ci: publish 2026-06-04 19:23:41 -04:00
Luke Parker b1a7ee5695 feat(desktop): surface local server startup failures (#30822) 2026-06-04 22:45:48 +00:00
opencode-agent[bot] 107180701f chore: update nix node_modules hashes 2026-06-04 20:16:53 +00:00
Sebastian e464999eb9 upgrade opentui to 0.3.2 (#30748) 2026-06-04 22:05:59 +02:00
Aiden Cline cc9b73b0bd tweak: background agent prompting to avoid polling issues (#30790) 2026-06-04 14:18:03 -05:00
opencode-agent[bot] fe5ac304b5 chore: generate 2026-06-04 19:09:33 +00:00
James Long ba1b660d57 feat(core): moving sessions (#30640) 2026-06-04 15:07:46 -04:00
Colin McDonnell e45e0e1125 fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) 2026-06-04 14:05:51 -05:00
Aiden Cline 5b14fb4a1b fix(tui): add Vue syntax highlighting (#30802) 2026-06-04 14:02:11 -05:00
Aiden Cline 730ea6d2e3 fix(opencode): attribute task child agent on creation (#30786) 2026-06-04 13:19:46 -05:00
LIU Xinyu 21a0fdd4c0 fix(opencode): ACP.loadSession should replay all messages (#30761)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-04 23:14:21 +05:30
Ariane Emory cb65926c82 fix(tui): show toast when variant_list keybind used with no variants (#30724) 2026-06-04 12:14:24 -05:00
Dax Raad 94c49b20ba make scripts executable 2026-06-04 11:19:36 -04:00
opencode-agent[bot] 9f3a0fe1d0 chore: update nix node_modules hashes 2026-06-04 15:15:12 +00:00
Dax Raad 7f54b1bfb8 fix build 2026-06-04 11:11:23 -04:00
opencode-agent[bot] 789e4d57b9 fix(enterprise): install hono standard validator peer (#30740)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-04 10:55:22 -04:00
Frank f5d1ae6a9e zen: nemotron 3 ultra 2026-06-04 10:40:58 -04:00
Shoubhit Dash 7e09660c3b fix(opencode): respect disabled auto compaction on overflow (#30749) 2026-06-04 19:47:49 +05:30
Shoubhit Dash 6d4f3b4ab2 feat(tui): improve experimental session switcher (#30738) 2026-06-04 19:22:01 +05:30
Kit Langton caea930074 fix(core): reset pre-launch session projections (#30728) 2026-06-04 09:43:10 -04:00
opencode-agent[bot] 69cfc44dba fix(acp): replay loaded session transcript (#30645)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-04 12:35:57 +05:30
opencode-agent[bot] 30ec231aaf chore: generate 2026-06-04 06:59:09 +00:00
Dax 1ff19103a2 feat(core): add command registry (#30624) 2026-06-04 02:57:43 -04:00
Aiden Cline 70bb710715 refactor(opencode): clean up task tool prompts (#30687) 2026-06-04 00:44:00 -05:00
opencode-agent[bot] 51fd7c0a3a chore: update nix node_modules hashes 2026-06-04 05:25:53 +00:00
Aiden Cline 74a27dbf98 test: wait for shell truncation readiness (#30679) 2026-06-04 00:20:35 -05:00
Aiden Cline 9f42bd4a85 feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) 2026-06-04 00:09:12 -05:00
Aiden Cline 2a33addd29 fix(opencode): avoid shell cancel race (#30641) 2026-06-03 22:44:25 -05:00
Aiden Cline 9251e5d8c4 docs: correct compaction prune default (#30670) 2026-06-03 22:37:39 -05:00
opencode-agent[bot] b6305cb4cb chore: update nix node_modules hashes 2026-06-04 03:15:13 +00:00
opencode-agent[bot] b0a929440b chore: generate 2026-06-04 03:03:39 +00:00
Kit Langton 76ee87ead8 feat(core): add embedded v2 session runtime and tool foundation (#30632) 2026-06-03 23:02:17 -04:00
Brendan Allan c35267776a fix(app,ui): session review reactivity and VCS query cache (#30660) 2026-06-04 02:59:56 +00:00
Luke Parker 55bafa29d4 feat(app): v2 thinking level selector (#30646) 2026-06-04 11:35:30 +10:00
Luke Parker f62ba5eb86 fix(app): hide unavailable titlebar update (#30642) 2026-06-03 23:51:40 +00:00
opencode-agent[bot] 04b38ce830 chore: generate 2026-06-03 23:42:15 +00:00
Jérôme Benoit 0b796c5f3d fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) 2026-06-03 18:40:39 -05:00
Frank fa6ea8bd25 zen: update nvidia tos 2026-06-03 19:09:02 -04:00
Aiden Cline ee74dd83f5 fix(opencode): preserve variant for delegated tasks (#30630) 2026-06-03 17:43:47 -05:00
Aiden Cline d3d4335509 chore: remove zed extension and automation (#30628) 2026-06-03 17:16:21 -05:00
Adam 2c32f7e520 fix(stats): count all go usage 2026-06-03 17:06:33 -05:00
opencode-agent[bot] 36234c462c chore: update nix node_modules hashes 2026-06-03 21:16:06 +00:00
opencode-agent[bot] a41f774cad chore: generate 2026-06-03 21:00:33 +00:00
Dax 889e0f9545 feat(core): add skill registry and file agent loading (#30617) 2026-06-03 16:58:34 -04:00
Aiden Cline 9991a33e3f fix(core): expose azure openai xhigh efforts (#30620) 2026-06-03 15:27:23 -05:00
Dax 11dbd15812 refactor(core): nest model api id (#30603) 2026-06-03 14:11:38 -04:00
opencode-agent[bot] af6383485b chore: generate 2026-06-03 17:51:22 +00:00
Dax 5900345f8c test(core): cover v1 provider option lowering (#30599) 2026-06-03 17:49:28 +00:00
opencode-agent[bot] a0e4db3714 chore: generate 2026-06-03 17:35:02 +00:00
Dax 1520b0de20 refactor(core): remove ai sdk option fields (#30581) 2026-06-03 17:32:58 +00:00
Aiden Cline 7f8412ec3e fix(openai): preserve websocket idle state (#30586) 2026-06-03 12:23:00 -05:00
Jack 400f0fb100 docs(go): add Qwen3.7 Plus model (#30594) 2026-06-04 00:40:19 +08:00
Adam d08fedaccc fix(stats): serve stats og image from banner 2026-06-03 10:53:07 -05:00
James Long f4851e3bd9 fix(tui): route question responses by session directory (#30578) 2026-06-03 15:11:55 +00:00
Shoubhit Dash 8851e4de2b fix(acp): clean read tool display content (#30569) 2026-06-03 19:36:27 +05:30
Shoubhit Dash e707e416ed fix(acp): include external directory permission context (#30567) 2026-06-03 19:05:28 +05:30
Shoubhit Dash fc62b3dc48 fix(acp): classify task as think (#30565) 2026-06-03 18:55:55 +05:30
Shoubhit Dash 89b26e8658 fix(acp): classify apply_patch as edit (#30564) 2026-06-03 18:51:41 +05:30
opencode-agent[bot] 5970f7820e chore: update nix node_modules hashes 2026-06-03 12:21:13 +00:00
opencode-agent[bot] 0c31beb637 chore: generate 2026-06-03 12:04:03 +00:00
Adam 7af6eafb41 feat(stats): add geo breakdown (#30456) 2026-06-03 12:02:37 +00:00
opencode-agent[bot] 56ec4b6912 chore: update nix node_modules hashes 2026-06-03 10:05:12 +00:00
Simon Klee 06eecc5a6d tui: truncate sidebar file paths (#30531) 2026-06-03 11:54:24 +02:00
opencode-agent[bot] b6f76e29c5 chore: generate 2026-06-03 09:49:08 +00:00
Shoubhit Dash 932fb6c9ec refactor(core): consolidate pty service (#30537) 2026-06-03 15:17:46 +05:30
Brendan Allan c6f684366a feat(app): add servers tab to settings dialog (#29675) 2026-06-03 08:25:45 +00:00
opencode-agent[bot] c36a433a1f chore: generate 2026-06-03 07:30:40 +00:00
Brendan Allan 707166ae4a fix(ui): render whole-file patches as complete diffs (#30516) 2026-06-03 07:29:23 +00:00
Ulises Jeremias 01cc475923 fix(opencode): fallback to sh for curl upgrade (#30499)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-03 12:27:20 +05:30
Aarav Sareen a444410cd4 feat(app): new update button (#30460)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-03 06:40:12 +00:00
1489 changed files with 91182 additions and 63461 deletions
+2 -4
View File
@@ -1,5 +1,3 @@
# web + desktop packages # web + desktop packages
packages/app/ @adamdotdevin packages/app/ @Hona @Brendonovich
packages/tauri/ @adamdotdevin packages/desktop/ @Hona @Brendonovich
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
+12 -2
View File
@@ -56,14 +56,24 @@ jobs:
BUILD_LOG=$(mktemp) BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT trap 'rm -f "$BUILD_LOG"' EXIT
HASH=""
MAX_ATTEMPTS=3
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
# Build with fakeHash to trigger hash mismatch and reveal correct hash # 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 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 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" cat "$BUILD_LOG"
exit 1 exit 1
fi fi
+35 -6
View File
@@ -90,6 +90,7 @@ jobs:
id: build id: build
run: | run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -107,6 +108,12 @@ jobs:
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows* path: packages/opencode/dist/opencode-windows*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
@@ -327,9 +334,9 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package and publish - name: Package
if: needs.version.outputs.release 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 working-directory: packages/desktop
timeout-minutes: 60 timeout-minutes: 60
env: env:
@@ -349,11 +356,9 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} 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 if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop/dist working-directory: packages/desktop/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: | run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac" APP_DIR="mac"
@@ -371,7 +376,6 @@ jobs:
exit 1 exit 1
fi fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" 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 - name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -446,12 +450,24 @@ jobs:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release if: needs.version.outputs.release
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/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 - name: Setup git committer
id: committer id: committer
uses: ./.github/actions/setup-git-committer uses: ./.github/actions/setup-git-committer
@@ -478,6 +494,19 @@ jobs:
git config --global user.name "opencode" git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true 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 - run: ./script/publish.ts
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
-35
View File
@@ -1,35 +0,0 @@
name: "sync-zed-extension"
on:
workflow_dispatch:
release:
types: [published]
jobs:
zed:
name: Release Zed Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Get version tag
id: get_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1)
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Using tag: ${TAG}"
- name: Sync Zed extension
run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env:
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
+2 -23
View File
@@ -64,7 +64,8 @@ jobs:
turbo-${{ runner.os }}- turbo-${{ runner.os }}-
- name: Run unit tests - name: Run unit tests
run: bun turbo test:ci timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -73,26 +74,6 @@ jobs:
working-directory: packages/opencode working-directory: packages/opencode
run: bun run test:httpapi 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: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
strategy: strategy:
@@ -150,7 +131,6 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
@@ -161,6 +141,5 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: | path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
+2
View File
@@ -15,6 +15,8 @@ ts-dist
.turbo .turbo
**/.serena **/.serena
.serena/ .serena/
**/.omo
.omo/
/result /result
refs refs
Session.vim Session.vim
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
mode: primary mode: primary
hidden: true hidden: true
model: opencode/gpt-5.4-nano model: opencode/gpt-5.4-mini
color: "#44BA81" color: "#44BA81"
tools: tools:
"*": false "*": false
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
description: translate English to other languages description: translate English to other languages
model: opencode/claude-opus-4-7 model: opencode/claude-opus-4-8
--- ---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
+9 -2
View File
@@ -2,8 +2,15 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": {}, "provider": {},
"permission": {}, "permission": {},
"reference": { "references": {
"effect": "github.com/Effect-TS/effect-smol", "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": {}, "mcp": {},
"tools": { "tools": {
+18
View File
@@ -2,6 +2,12 @@
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - 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 ## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`. Use conventional commit-style messages and PR titles: `type(scope): summary`.
@@ -138,3 +144,15 @@ const table = sqliteTable("session", {
## Type Checking ## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. - Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
## V2 Session Core
- 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. 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.
+124
View File
@@ -0,0 +1,124 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**System Context**:
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
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
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**.
_Avoid_: Live system prompt
**Context Snapshot**:
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
**Unavailable Context**:
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**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.
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- 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
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
+928 -1081
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true exact = true
# Only install newly resolved package versions published at least 3 days ago. # Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200 minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@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] [test]
root = "./do-not-run-tests-from-root" 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}`, domain: `stats.${domain}`,
link: [database, EMAILOCTOPUS_API_KEY], link: [database, EMAILOCTOPUS_API_KEY],
environment: { environment: {
PUBLIC_URL: `https://stats.${domain}/stats`, PUBLIC_URL: `https://${domain}/data`,
}, },
}) })
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fzplLKjzYefVCRhAgfCWWIa3me2JiI2d9kDBACnz4fA=", "x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
"aarch64-linux": "sha256-gccipj2CAau/lZ7KTQhdeU8n6fcXDuUt4X4XbNufCko=", "aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
"aarch64-darwin": "sha256-r0QEetxKUf6HrtMOozyvb4jjEJjVtp+DE8aiKKhRrVI=", "aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
"x86_64-darwin": "sha256-bU+5WudkiIflFgoqgLSf30iqeNNLuEbScI5oMFPu9eM=" "x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
} }
} }
+8 -6
View File
@@ -15,7 +15,7 @@
"lint": "oxlint", "lint": "oxlint",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser", "sso": "aws sso login --sso-session=opencode --no-browser",
@@ -37,10 +37,11 @@
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.1", "@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.1", "@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.1", "@opentui/solid": "0.3.4",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@@ -51,7 +52,7 @@
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
@@ -139,7 +140,8 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "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", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
@@ -0,0 +1,87 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
test("shows the V2 thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-thinking-level-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"thinking-model": {
id: "thinking-model",
name: "Thinking Model",
limit: { context: 200_000 },
variants: { high: {} },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "thinking-model" },
},
sessions: [
{
id: sessionID,
slug: "prompt-thinking-level-regression",
projectID,
directory,
title: "Prompt thinking level regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('[data-component="prompt-variant-control"]')
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test" import { test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture" import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
test("shows loaded sessions before the directory path request resolves", async ({ page }) => { test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async (
await page.goto("/") await page.goto("/")
try { try {
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 }) await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
} finally { } finally {
releasePath() releasePath()
} }
@@ -1,5 +1,6 @@
import { expect, test, type Locator, type Page } from "@playwright/test" import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TimelineStateRegression" const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression" const projectID = "proj_timeline_state_regression"
@@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible() await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible() await expectAppVisible(wrapper)
await expectExpanded(wrapper, true) await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => { await wrapper.evaluate((element) => {
@@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible() await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible() await expectAppVisible(wrapper)
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible() await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
await markDiffProbe(page) await markDiffProbe(page)
events.push({ events.push({
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ContextResizeRegression" const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression" const projectID = "proj_context_resize_regression"
@@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => {
await configurePage(page) await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible() await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible() await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible() await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
await settle(page) await settle(page)
const samples = await sampleExpansion(page) const samples = await sampleExpansion(page)
@@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture" import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
const forbiddenText = ["Load details", "Show earlier steps"] const forbiddenText = ["Load details", "Show earlier steps"]
@@ -411,18 +412,21 @@ function expectCompleteScroll(
async function selectHomeProject(page: Page, projectName: string) { async function selectHomeProject(page: Page, projectName: string) {
await page.goto("/") await page.goto("/")
await page const row = page
.locator('[data-component="home-project-row"]') .locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") }) .filter({ hasText: new RegExp(projectName, "i") })
.click() .first()
await expectAppVisible(row)
await row.click()
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
await expect(page).toHaveURL(/\/$/) await expect(page).toHaveURL(/\/$/)
} }
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible() await expectSessionTitle(page, expectedTitle)
} }
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page) {
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible() await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
} }
+11
View File
@@ -0,0 +1,11 @@
import { expect, type Locator, type Page } from "@playwright/test"
export const APP_READY_TIMEOUT = 30_000
export async function expectAppVisible(locator: Locator) {
await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
}
export async function expectSessionTitle(page: Page, title: string) {
await expectAppVisible(page.getByRole("heading", { name: title }))
}
+4 -3
View File
@@ -1,11 +1,13 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.13", "version": "1.17.4",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts", "./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
"./index.css": "./src/index.css" "./index.css": "./src/index.css"
}, },
@@ -16,8 +18,7 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "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 --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "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 command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined 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({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
outputDir: "./e2e/test-results", outputDir: "./e2e/test-results",
@@ -24,7 +18,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers, workers,
reporter, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: { webServer: {
command, command,
url: baseURL, url: baseURL,
+111 -31
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" 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 { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
@@ -25,7 +25,6 @@ import {
onCleanup, onCleanup,
type ParentProps, type ParentProps,
Show, Show,
Suspense,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
@@ -33,6 +32,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
@@ -43,24 +43,88 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
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 Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
const HomeRoute = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign( const SessionRoute = Object.assign(
() => ( () => {
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> <SessionProviders>
<Session /> <Session />
</SessionProviders> </SessionProviders>
), )
},
{ preload: Session.preload }, { 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) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -69,9 +133,7 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean
} }
api?: { api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void> 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 }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
@@ -171,11 +245,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}} }}
> >
<QueryProvider> <QueryProvider>
<WslServersProvider>
<DialogProvider> <DialogProvider>
<MarkedProvider> <MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider> </MarkedProvider>
</DialogProvider> </DialogProvider>
</WslServersProvider>
</QueryProvider> </QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
@@ -211,26 +287,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
Effect.runPromise, Effect.runPromise,
), ),
) )
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
return ( return (
<Suspense <Show
when={!checking()}
fallback={ fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base"> <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" /> <Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div> </div>
} }
> >
{/*<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Show <Show
when={startupHealthCheck()} when={startupHealthCheck.latest}
fallback={ fallback={
<ConnectionError <ConnectionError
onRetry={() => { onRetry={() => {
@@ -246,8 +317,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
> >
{props.children} {props.children}
</Show> </Show>
{/*</Show>*/} </Show>
</Suspense>
) )
} }
@@ -310,34 +380,44 @@ function ServerKey(props: ParentProps) {
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
}) { }) {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider
<ServersProvider> defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerKey> <ServerKey>
<QueryProvider> <QueryProvider>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider> <ServerSyncProvider>
<Dynamic <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
component={props.router ?? Router} </ServerSyncProvider>
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} </ServerSDKProvider>
</QueryProvider>
</ServerKey>
</TabsProvider>
)}
> >
<Route path="/" component={HomeRoute} /> <Route path="/" component={HomeRoute} />
<Route path="/new-session" component={DraftRoute} />
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Dynamic> </Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate> </ConnectionGate>
</ServersProvider> </GlobalProvider>
</ServerProvider> </ServerProvider>
) )
} }
@@ -277,6 +277,7 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">{select()?.message}</div> <div class="text-14-regular text-text-base">{select()?.message}</div>
<div> <div>
<List <List
class="px-3"
items={select()?.options ?? []} items={select()?.options ?? []}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
@@ -364,6 +365,7 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
<div> <div>
<List <List
class="px-3"
ref={(ref) => { ref={(ref) => {
listRef = ref listRef = ref
}} }}
@@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject }) { export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog() const dialog = useDialog()
const serverSDK = useServerSDK() const global = useGlobal()
const serverSync = useServerSync()
const language = useLanguage() const language = useLanguage()
const serverCtx = createMemo(() => global.createServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree)) const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName()) const defaultName = createMemo(() => props.project.name || folderName())
@@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
await serverSDK.client.project.update({ await serverSDK().client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color || "", override: store.iconOverride || "" }, icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start }, commands: { start },
}) })
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined) serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
} }
serverSync.project.meta(props.project.worktree, { serverSync().project.meta(props.project.worktree, {
name, name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined }, icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined }, commands: { start: start || undefined },
+1 -1
View File
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
return ( return (
<Dialog title={language.t("command.session.fork")}> <Dialog title={language.t("command.session.fork")}>
<List <List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0" class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")} emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id} key={(x) => x.id}
@@ -39,6 +39,7 @@ export const DialogManageModels: Component = () => {
} }
> >
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`} key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -6,15 +6,16 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useServerSDK } from "@/context/server-sdk" import { ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
multiple?: boolean multiple?: boolean
onSelect: (result: string | string[] | null) => void onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
} }
type Row = { type Row = {
@@ -127,11 +128,7 @@ function uniqueRows(rows: Row[]) {
}) })
} }
function useDirectorySearch(args: { function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
sdk: ReturnType<typeof useServerSDK>
start: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0 let current = 0
@@ -246,9 +243,8 @@ function useDirectorySearch(args: {
} }
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useServerSync() const global = useGlobal()
const sdk = useServerSDK() const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
const layout = useLayout()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -279,7 +275,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}) })
const recentProjects = createMemo(() => { const recentProjects = createMemo(() => {
const projects = layout.projects.list() const projects = serverCtx.projects.list()
const byProject = new Map<string, number>() const byProject = new Map<string, number>()
for (const project of projects) { for (const project of projects) {
@@ -324,6 +320,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ( return (
<Dialog title={props.title ?? language.t("command.project.open")}> <Dialog title={props.title ?? language.t("command.project.open")}>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")} emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")} loadingMessage={language.t("common.loading")}
@@ -386,6 +386,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return ( return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition> <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List <List
class="px-3"
search={{ search={{
placeholder: filesOnly() placeholder: filesOnly()
? language.t("session.header.searchFiles") ? language.t("session.header.searchFiles")
@@ -398,6 +399,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items} items={items}
key={(item) => item.id} key={(item) => item.id}
filterKeys={["title", "description", "category"]} filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""} groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove} onMove={handleMove}
onSelect={handleSelect} onSelect={handleSelect}
@@ -1,13 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync" import { useMcpToggle } from "@/context/mcp"
import { pathKey } from "@/utils/path-key"
const statusLabels = { const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
@@ -19,10 +16,7 @@ const statusLabels = {
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = useMutation(() => ({ const toggle = useMcpToggle()
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 enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -55,6 +35,7 @@ export const DialogSelectMcp: Component = () => {
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })} description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
> >
<List <List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")} emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""} key={(x) => x?.name ?? ""}
@@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={model.list} items={model.list}
current={model.current()} current={model.current()}
@@ -90,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div> <div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full"> <div class="w-full">
<List <List
class="w-full px-0" class="w-full px-3"
key={(p) => p.id} key={(p) => p.id}
items={providers.popular} items={providers.popular}
activeIcon="plus-small" activeIcon="plus-small"
@@ -37,7 +37,7 @@ const ModelList: Component<{
return ( return (
<List <List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`} class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
@@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => {
return ( return (
<Dialog title={language.t("command.provider.connect")} transition> <Dialog title={language.t("command.provider.connect")} transition>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")} emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small" activeIcon="plus-small"
@@ -9,13 +9,16 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
const DEFAULT_USERNAME = "opencode" const DEFAULT_USERNAME = "opencode"
@@ -71,7 +74,7 @@ function useDefaultServer() {
} }
} }
return { defaultKey, canDefault, setDefault } return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
} }
function useServerPreview() { function useServerPreview() {
@@ -121,7 +124,7 @@ function ServerForm(props: ServerFormProps) {
} }
return ( return (
<div class="px-5"> <div>
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3"> <div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField <TextField
@@ -172,16 +175,31 @@ function ServerForm(props: ServerFormProps) {
} }
export function DialogSelectServer() { export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close })
return (
<Dialog title={controller.formTitle()}>
<div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
<ServerConnectionForm controller={controller} />
</Show>
</div>
</Dialog>
)
}
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer() const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer() const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview() const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
name: "", name: "",
@@ -247,6 +265,11 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true) await select(conn, true)
}, },
})) }))
@@ -295,12 +318,14 @@ export function DialogSelectServer() {
})) }))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next) const newConn = server.add(next)
if (!newConn) return if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original)) server.remove(originalKey)
} }
const items = createMemo(() => { const items = createMemo(() => {
@@ -311,7 +336,12 @@ export function DialogSelectServer() {
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -326,32 +356,16 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
}) })
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
items()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
async function select(conn: ServerConnection.Any, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() options.onSelect?.()
if (persist && conn.type === "http") { if (persist && conn.type === "http") {
server.add(conn) server.add(conn)
navigate("/") navigate("/")
@@ -457,7 +471,7 @@ export function DialogSelectServer() {
username: conn.http.username ?? "", username: conn.http.username ?? "",
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: global.servers.health[ServerConnection.key(conn)]?.healthy,
}) })
} }
@@ -495,66 +509,84 @@ export function DialogSelectServer() {
resetEdit() resetEdit()
}) })
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(key: ServerConnection.Key) {
server.remove(url) try {
if ((await platform.getDefaultServer?.()) === url) { if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
void platform.setDefaultServer?.(null) tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
} }
} }
return ( return {
<Dialog title={formTitle()}> defaultKey,
<div class="flex flex-1 min-h-0 flex-col gap-2"> canDefault,
<Show current,
when={!isFormMode()} sortedItems,
fallback={ status: () => global.servers.health,
<ServerForm isFormMode,
value={isAddMode() ? store.addServer.url : store.editServer.value} isAddMode,
name={isAddMode() ? store.addServer.name : store.editServer.name} formTitle,
username={isAddMode() ? store.addServer.username : store.editServer.username} formBusy,
password={isAddMode() ? store.addServer.password : store.editServer.password} formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
placeholder={language.t("dialog.server.add.placeholder")} formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
busy={formBusy()} formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
error={isAddMode() ? store.addServer.error : store.editServer.error} formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
status={isAddMode() ? store.addServer.status : store.editServer.status} formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
onChange={isAddMode() ? handleAddChange : handleEditChange} formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange} select,
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange} setDefault,
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange} startAdd,
onSubmit={submitForm} startEdit,
onBack={resetForm} resetForm,
/> submitForm,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
} }
> }
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
const settings = useSettings()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<List <List
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
search={{ search={{
placeholder: language.t("dialog.server.search.placeholder"), placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false, autofocus: false,
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={sortedItems} items={props.controller.sortedItems}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x) void select(x) if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
}} }}
divider={true} divider={true}
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i) const key = ServerConnection.key(i)
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={store.status[key]} /> <ServerHealthIndicator health={props.controller.status()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={store.status[key]?.healthy === false} dimmed={props.controller.status()[key]?.healthy === false}
status={store.status[key]} status={props.controller.status()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={defaultKey() === ServerConnection.key(i)}> <Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -563,7 +595,7 @@ export function DialogSelectServer() {
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}> <Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -582,20 +614,18 @@ export function DialogSelectServer() {
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
startEdit(i) props.controller.startEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}> <Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}> <DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={canDefault() && defaultKey() === key}> <Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}> <DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
@@ -603,7 +633,7 @@ export function DialogSelectServer() {
</Show> </Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))} onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -617,33 +647,58 @@ export function DialogSelectServer() {
) )
}} }}
</List> </List>
</Show>
<div class="shrink-0 px-5 pb-5"> <div class="shrink-0 pb-5">
<Show
when={isFormMode()}
fallback={
<Button <Button
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={startAdd} onClick={props.controller.startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
</Button> </Button>
} </div>
</div>
)
}
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.controller.formValue()}
name={props.controller.formName()}
username={props.controller.formUsername()}
password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()}
error={props.controller.formError()}
status={props.controller.formStatus()}
onChange={props.controller.handleFormChange()}
onNameChange={props.controller.handleFormNameChange()}
onUsernameChange={props.controller.handleFormUsernameChange()}
onPasswordChange={props.controller.handleFormPasswordChange()}
onSubmit={props.controller.submitForm}
onBack={props.controller.resetForm}
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
size="large"
onClick={props.controller.submitForm}
disabled={props.controller.formBusy()}
class="px-3 py-1.5"
> >
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5"> {props.controller.formBusy()
{formBusy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: isAddMode() : props.controller.isAddMode()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
</Show>
</div> </div>
</div> </div>
</Dialog>
) )
} }
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds" import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers" import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models" import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => { export const DialogSettings: Component = () => {
const language = useLanguage() const language = useLanguage()
@@ -17,7 +18,7 @@ export const DialogSettings: Component = () => {
<Dialog size="x-large" transition> <Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog"> <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List> <Tabs.List>
<div class="flex flex-col justify-between h-full w-full"> <div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3"> <div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -31,6 +32,10 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" /> <Icon name="keyboard" />
{language.t("settings.tab.shortcuts")} {language.t("settings.tab.shortcuts")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div> </div>
</div> </div>
@@ -61,6 +66,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar"> <Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds /> <SettingsKeybinds />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar"> <Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders /> <SettingsProviders />
</Tabs.Content> </Tabs.Content>
@@ -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", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@/context/file", () => ({ mock.module("@/context/file", () => ({
useFile: () => ({ 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>
)
}
+79 -15
View File
@@ -31,9 +31,10 @@ import {
FileAttachmentPart, FileAttachmentPart,
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router" import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" 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 { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments" import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
@@ -72,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" 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 { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query" import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync" import { useQueryOptions } from "@/context/server-sync"
@@ -139,7 +142,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings() const settings = useSettings()
const tabsStore = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
@@ -277,6 +283,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
variantOpen: boolean
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
@@ -285,6 +292,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
}) })
const [picker, setPicker] = createStore({ const [picker, setPicker] = createStore({
projectOpen: false, projectOpen: false,
@@ -463,7 +471,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" 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") => { const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode) setStore("mode", mode)
@@ -623,6 +644,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
key: atKey, key: atKey,
filterKeys: ["display"], filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => { groupBy: (item) => {
if (item.type === "agent") return "agent" if (item.type === "agent") return "agent"
if (item.recent) return "recent" if (item.recent) return "recent"
@@ -1073,7 +1095,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
@@ -1101,6 +1123,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
const variants = createMemo(() => ["default", ...local.model.variant.list()]) const variants = createMemo(() => ["default", ...local.model.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const accepting = createMemo(() => { const accepting = createMemo(() => {
const id = params.id const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
@@ -1360,23 +1384,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
layout.projects.open(worktree) layout.projects.open(worktree)
server.projects.touch(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`) navigate(`/${base64Encode(worktree)}/session`)
} }
const addProject = async () => { const addProject = () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => { const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result const directory = Array.isArray(result) ? result[0] : result
if (!directory) return if (!directory) return
selectProject(directory) selectProject(directory)
} }
if (platform.openDirectoryPickerDialog && server.isLocal()) { pickDirectory({
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") })) server: conn,
return title: language.t("command.project.open"),
} onSelect: select,
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} />,
() => select(null),
)
}) })
} }
@@ -1569,6 +1600,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ComposerPickerTrigger state={newProjectTriggerState()} /> <ComposerPickerTrigger state={newProjectTriggerState()} />
</Show> </Show>
<ComposerModelControl state={modelControlState()} /> <ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div> </div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
@@ -1888,7 +1952,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
<Show when={variants().length > 2}> <Show when={showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files" import { attachmentMime, pickAttachmentFiles } from "./files"
import { pasteMode } from "./paste" import { pasteMode } from "./paste"
describe("attachmentMime", () => { 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", () => { describe("pasteMode", () => {
test("uses native paste for short single-line text", () => { test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native") 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 } 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_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
["gif", "image/gif"], ["gif", "image/gif"],
@@ -61,6 +61,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => params, useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({ 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", () => ({ mock.module("@/context/prompt", () => ({
usePrompt: () => ({ usePrompt: () => ({
current: () => promptValue, current: () => promptValue,
@@ -127,6 +139,7 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
const sdk = { const sdk = {
scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
url: "http://localhost:4096", url: "http://localhost:4096",
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" 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 { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -18,6 +20,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -212,6 +215,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() 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) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
@@ -232,11 +239,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.onAbort?.() input.onAbort?.()
const queued = pending.get(sessionID) const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
pending.delete(sessionID) pending.delete(key)
return Promise.resolve() return Promise.resolve()
} }
return sdk.client.session return sdk.client.session
@@ -341,7 +349,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return return
} }
WorktreeState.pending(createdWorktree.directory) WorktreeState.pending(sdk.scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -378,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(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) { if (!session) {
@@ -500,7 +515,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput() clearInput()
const waitForWorktree = async () => { const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory) const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
@@ -517,7 +532,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
restoreInput() restoreInput()
} }
pending.set(session.id, { abort: controller, cleanup }) pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@@ -544,11 +559,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs) }, timeoutMs)
}) })
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
() => {
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}) },
pending.delete(session.id) )
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message) if (result.status === "failed") throw new Error(result.message)
return true return true
@@ -563,7 +580,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree, before: waitForWorktree,
}).catch((err) => { }).catch((err) => {
pending.delete(session.id) pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session.id, { type: "idle" })
} }
@@ -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>
)
}
@@ -4,7 +4,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) { export function NewSessionDesignView(props: { children: JSX.Element }) {
return ( return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep"> <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"> <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}> <div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" /> <WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" 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 { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select" 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 { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform" import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
const params = useParams() const params = useParams()
const settings = useSettings() const settings = useSettings()
const [store, setStore] = createStore({ const updater = useUpdaterAction()
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir)) const dir = createMemo(() => decode64(params.dir))
@@ -123,66 +120,14 @@ export const SettingsGeneral: Component = () => {
} }
const desktop = createMemo(() => platform.platform === "desktop") 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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync() const serverSync = useServerSync()
const globalSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
globalSdk.client.pty serverSdk.client.pty
.shells() .shells()
.then((res) => res.data ?? []) .then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]), .catch(() => [] as ShellOption[]),
@@ -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> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList> <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 <SettingsRow
title={language.t("settings.general.row.releaseNotes.title")} title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")} description={language.t("settings.general.row.releaseNotes.description")}
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} description={language.t("settings.updates.row.check.description")}
> >
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}> <Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{store.checking {language.t(updater.action().label)}
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button> </Button>
</SettingsRow> </SettingsRow>
</SettingsList> </SettingsList>
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -32,6 +33,14 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) =
} }
export const SettingsModels: Component = () => { export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
@@ -61,7 +70,10 @@ export const SettingsModels: Component = () => {
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base"> <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" /> <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField <TextField
@@ -12,6 +12,7 @@ import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -28,6 +29,14 @@ const PROVIDER_NOTES = [
] as const ] as const
export const SettingsProviders: Component = () => { export const SettingsProviders: Component = () => {
return (
<SettingsServerScope>
<SettingsProvidersContent />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@@ -129,8 +138,9 @@ export const SettingsProviders: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]"> <div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div> </div>
</div> </div>
@@ -0,0 +1,106 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClientProvider } from "@tanstack/solid-query"
import { createMemo, For, type ParentProps, Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
export function SettingsServerScope(props: ParentProps) {
const global = useGlobal()
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
<Show when={global.settings.server.selected()}>
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
</Show>
</Show>
)
}
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.createServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryClientProvider>
)
}
export function SettingsServerPicker() {
const global = useGlobal()
const settings = useSettings()
const selected = createMemo(() =>
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
)
return (
<Show when={selected()}>
{(conn) => (
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={Button}
variant="secondary"
size="large"
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
>
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
<ServerRow
conn={conn()}
status={global.servers.health[ServerConnection.key(conn())]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="hidden"
/>
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
<DropdownMenu.RadioGroup
value={global.settings.server.key}
onChange={(key) => {
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
}}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={item}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
/>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Show>
)
}
@@ -0,0 +1,33 @@
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<Show
when={controller.isFormMode()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
</div>
</Show>
</div>
</div>
)
}
@@ -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,20 +9,15 @@ import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers" import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
export const DialogSettings: Component = () => { export const DialogSettings: Component = () => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
return ( return (
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog"> <Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2 <TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
orientation="vertical"
variant="settings"
defaultValue="general"
class="settings-v2"
data-component="settings-v2"
>
<TabsV2.List> <TabsV2.List>
<div class="flex flex-col justify-between h-full w-full"> <div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full"> <div class="flex flex-col gap-3 w-full">
@@ -44,6 +39,10 @@ export const DialogSettings: Component = () => {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle> <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full"> <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"> <TabsV2.Trigger value="providers">
<Icon name="providers" /> <Icon name="providers" />
{language.t("settings.providers.title")} {language.t("settings.providers.title")}
@@ -68,6 +67,9 @@ export const DialogSettings: Component = () => {
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 /> <SettingsProvidersV2 />
</TabsV2.Content> </TabsV2.Content>
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js" 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 { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" 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 { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform" import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
const params = useParams() const params = useParams()
const settings = useSettings() const settings = useSettings()
const [store, setStore] = createStore({ const updater = useUpdaterAction()
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir)) const dir = createMemo(() => decode64(params.dir))
@@ -125,66 +122,14 @@ export const SettingsGeneralV2: Component = () => {
} }
const desktop = createMemo(() => platform.platform === "desktop") 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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useServerSync() const serverSync = useServerSync()
const globalSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
globalSdk.client.pty serverSdk.client.pty
.shells() .shells()
.then((res) => res.data ?? []) .then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]), .catch(() => [] as ShellOption[]),
@@ -208,11 +153,11 @@ export const SettingsGeneralV2: Component = () => {
}) })
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "") const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => { const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest const list = shells.latest
const current = globalSync.data.config.shell const current = serverSync.data.config.shell
const nameCounts = new Map<string, number>() const nameCounts = new Map<string, number>()
for (const s of list) { for (const s of list) {
@@ -347,7 +292,7 @@ export const SettingsGeneralV2: Component = () => {
onSelect={(option) => { onSelect={(option) => {
if (!option) return if (!option) return
if (option.value === currentShell()) return if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value }) serverSync.updateConfig({ shell: option.value })
}} }}
/> />
</SettingsRowV2> </SettingsRowV2>
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2> <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 <SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")} title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")} description={language.t("settings.general.row.releaseNotes.description")}
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} description={language.t("settings.updates.row.check.description")}
> >
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}> <ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{store.checking {language.t(updater.action().label)}
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</ButtonV2> </ButtonV2>
</SettingsRowV2> </SettingsRowV2>
</SettingsListV2> </SettingsListV2>
@@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => { export const SettingsProvidersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const globalSDK = useServerSDK() const serverSdk = useServerSDK()
const globalSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders() const providers = useProviders()
const connected = createMemo(() => { const connected = createMemo(() => {
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => { const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID] const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
const before = globalSync.data.config.disabled_providers ?? [] const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
globalSync.set("config", "disabled_providers", next) serverSync.set("config", "disabled_providers", next)
await globalSync await serverSync
.updateConfig({ disabled_providers: next }) .updateConfig({ disabled_providers: next })
.then(() => { .then(() => {
showToast({ showToast({
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
}) })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
globalSync.set("config", "disabled_providers", before) serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
}) })
@@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) { if (isConfigCustom(providerID)) {
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined) await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name) await disableProvider(providerID, name)
return return
} }
await globalSDK.client.auth await serverSdk.client.auth
.remove({ providerID }) .remove({ providerID })
.then(async () => { .then(async () => {
await globalSDK.client.global.dispose() await serverSdk.client.global.dispose()
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -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>
</>
)
}
@@ -1,11 +1,15 @@
@import "@opencode-ai/ui/v2/text-input-v2.css"; @import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css"; @import "@opencode-ai/ui/v2/button-v2.css";
[data-component="settings-v2"] { [data-component="tabs-v2"][data-variant="settings"] {
height: 100%; height: 100%;
} }
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] { [data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-body"] {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@@ -81,10 +85,6 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
} }
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
box-shadow: var(--v2-elevation-overlay);
}
[data-component="settings-v2-row"] { [data-component="settings-v2-row"] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -153,12 +153,12 @@
width: 100%; width: 100%;
} }
[data-component="settings-v2-dialog"] [data-component="select-v2-root"] { [data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
width: fit-content; width: fit-content;
max-width: 100%; max-width: 100%;
} }
[data-component="settings-v2-dialog"] [data-component="button-v2"] { [data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
width: fit-content; width: fit-content;
max-width: 100%; max-width: 100%;
} }
@@ -511,3 +511,144 @@
.settings-v2-shortcuts-status-filter { .settings-v2-shortcuts-status-filter {
color: var(--v2-text-text-base); 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 { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -11,15 +10,12 @@ import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { ServerConnection, useServer } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync" import { useGlobal } from "@/context/global"
import { pathKey } from "@/utils/path-key" import { useSettings } from "@/context/settings"
import { useServers } from "@/context/servers" import { useMcpToggle } from "@/context/mcp"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -59,7 +55,7 @@ const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [state, setState] = createStore({ const [state, setState] = createStore({
url: undefined as string | undefined, key: undefined as ServerConnection.Key | undefined,
tick: 0, tick: 0,
}) })
@@ -68,7 +64,7 @@ const useDefaultServerKey = (
let dead = false let dead = false
const result = get?.() const result = get?.()
if (!result) { if (!result) {
setState("url", undefined) setState("key", undefined)
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -78,7 +74,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) { if (result instanceof Promise) {
void result.then((next) => { void result.then((next) => {
if (dead) return if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined) setState("key", next ?? undefined)
}) })
onCleanup(() => { onCleanup(() => {
dead = true dead = true
@@ -86,7 +82,7 @@ const useDefaultServerKey = (
return return
} }
setState("url", normalizeServerUrl(result)) setState("key", ServerConnection.Key.make(result))
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -94,45 +90,12 @@ const useDefaultServerKey = (
return { return {
key: () => { key: () => {
const u = state.url return state.key
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
}, },
refresh: () => setState("tick", (value) => value + 1), 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 = { type ServerStatusState = {
servers: () => ServerStatusItem[] servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined defaultKey: () => ServerConnection.Key | undefined
@@ -153,13 +116,12 @@ type ServerStatusItem = {
} }
export function StatusPopoverServerBody() { export function StatusPopoverServerBody() {
const servers = useServers() const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
let dialogRun = 0 let dialogRun = 0
let dialogDead = false let dialogDead = false
onCleanup(() => { onCleanup(() => {
@@ -167,7 +129,7 @@ export function StatusPopoverServerBody() {
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health)) const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() => const serverItems = createMemo(() =>
sortedServers().map((conn) => { sortedServers().map((conn) => {
@@ -175,8 +137,8 @@ export function StatusPopoverServerBody() {
return { return {
key, key,
conn, conn,
health: servers.health[key], health: global.servers.health[key],
blocked: servers.health[key]?.healthy === false, blocked: global.servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current), active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => { onSelect: () => {
navigate("/") navigate("/")
@@ -288,12 +250,13 @@ function ServerStatusList(props: { state: ServerStatusState }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const servers = useServers() const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -313,8 +276,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health)) const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggleMutation() const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -333,15 +296,17 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-label={language.t("status.popover.ariaLabel")} aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden" class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs" data-component="tabs"
data-active="servers" data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
defaultValue="servers" defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
variant="alt" variant="alt"
> >
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{servers.list().length > 0 ? `${servers.list().length} ` : ""} {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
)}
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
@@ -356,13 +321,14 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
{!settings.general.newLayoutDesigns() && (
<Tabs.Content value="servers"> <Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(s) => { {(s) => {
const key = ServerConnection.key(s) const key = ServerConnection.key(s)
const blocked = () => servers.health[key]?.healthy === false const blocked = () => global.servers.health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -378,11 +344,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
queueMicrotask(() => server.setActive(key)) queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={servers.health[key]} /> <ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow <ServerRow
conn={s} conn={s}
dimmed={blocked()} dimmed={blocked()}
status={servers.health[key]} status={global.servers.health[key]}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
@@ -420,6 +386,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>
)}
<Tabs.Content value="mcp"> <Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
@@ -7,7 +7,7 @@ import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useServers } from "@/context/servers" import { useGlobal } from "@/context/global"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -15,10 +15,10 @@ const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ def
export function StatusPopover() { export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const servers = useServers() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready) const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => { const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {}) const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -26,8 +26,8 @@ export function StatusPopover() {
if (failed) return "critical" as const if (failed) return "critical" as const
if (warn) return "warning" as const if (warn) return "warning" as const
}) })
const serverHealthy = () => servers.health[server.key]?.healthy === true const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue()) const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
return ( return (
<Popover <Popover
@@ -82,10 +82,10 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
function DirectoryStatusPopover() { function DirectoryStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const servers = useServers() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready) const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => { const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {}) const mcp = Object.values(sync.data.mcp ?? {})
@@ -116,9 +116,9 @@ function DirectoryStatusPopover() {
function ServerStatusPopover() { function ServerStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const servers = useServers() const global = useGlobal()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
+320 -192
View File
@@ -1,5 +1,17 @@
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" import {
import { createStore, produce } from "solid-js/store" createEffect,
createMemo,
createResource,
createSignal,
For,
Match,
onMount,
Show,
startTransition,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -9,7 +21,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -17,18 +29,16 @@ import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
readSessionTabsRemovedDetail, import { useGlobal } from "@/context/global"
SESSION_TABS_REMOVED_EVENT, import { decode64 } from "@/utils/base64"
type SessionTabsRemovedDetail, import { ServerConnection, useServer } from "@/context/server"
} from "@/components/titlebar-session-events" import { tabHref, useTabs, type Tab } from "@/context/tabs"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -56,8 +66,6 @@ const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = { export type TitlebarUpdate = {
version: () => string | undefined version: () => string | undefined
installing: () => boolean installing: () => boolean
@@ -71,6 +79,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const server = useServer()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams() const params = useParams()
@@ -121,10 +130,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const hasProjects = createMemo(() => layout.projects.list().length > 0) const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true)) const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => { const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing() ?? false
const version = props.update?.version() const version = props.update?.version()
return { return {
visible: version !== undefined, visible: version !== undefined || installing,
installing: props.update?.installing() ?? false, installing,
label: "Update", label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"), ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined, title: version ? `Update ${version}` : undefined,
@@ -219,9 +229,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
classList={{ classList={{
"shrink-0 relative overflow-hidden flex flex-row": true, "shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep": useV2Titlebar(), "h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(), "h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
}} }}
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
@@ -242,6 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const serverSync = useServerSync() const serverSync = useServerSync()
const navigate = useNavigate() const navigate = useNavigate()
const homeMatch = useMatch(() => "/") const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => { const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session` if (params.dir) return `/${params.dir}/session`
@@ -252,77 +263,64 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return `/${base64Encode(project.worktree)}/session` return `/${base64Encode(project.worktree)}/session`
} }
type Tab = { dir: string; sessionId: string; href: string } const tabs = useTabs()
const tabsStore = tabs.store
const [tabsStore, tabsStoreActions] = iife(() => { const tabsStoreActions = tabs
const [store, setStore] = createStore<Tab[]>( const navigateTab = (tab: Tab) => {
iife(() => { const href = tabHref(tab)
if (!params.dir || !params.id) return [] if (tab.server === server.key) {
return [ navigate(href)
{ return
dir: decodeDirectory(params.dir) ?? "", }
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
void startTransition(() => { void startTransition(() => {
setStore( server.setActive(tab.server)
produce((tabs) => { navigate(href)
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
}) })
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
} }
if (!removedCurrent) return const matchRoute = (route: LayoutRoute) => {
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1] if (route.type === "home") return
if (nextTab) navigate(nextTab.href) if (route.type === "draft") {
else navigate("/") return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
}), }
if (route.type === "session") {
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
) )
}) if (main) return main
}, const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
if (parent) return parent
}
}
} }
return [store, actions] const currentTab = () => matchRoute(layout.route())
createEffect(() => {
const route = layout.route()
if (!tabs.ready()) return
const tab = currentTab()
if (tab) return
if (route.type === "session") {
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next)
}
}) })
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
@@ -331,49 +329,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail) tabsStoreActions.removeSessions(detail)
}) })
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
})
})
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
return tabsStore.find((tab) => tab.href === href)
}
const closeCurrentSessionTab = () => {
const tab = currentSessionTab()
if (!tab) return false
tabsStoreActions.removeTab(tab.href)
return true
}
const closeNewSessionTab = () => {
if (!(params.dir && !params.id)) return false
const last = tabsStore[tabsStore.length - 1]
if (last) navigate(last.href)
else navigate("/")
return true
}
const openNewTab = () => navigate(newSessionHref()) const openNewTab = () => navigate(newSessionHref())
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab() command.register("tabs", () => {
const current = currentTab()
command.register(() => { return [
const commands = [
{ {
id: "tab.new", id: "tab.new",
category: "tab", category: "tab",
@@ -382,13 +343,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true, hidden: true,
onSelect: openNewTab, onSelect: openNewTab,
}, },
{ current && {
id: "tab.close", id: "tab.close",
category: "tab", category: "tab",
title: language.t("command.tab.close"), title: language.t("command.tab.close"),
keybind: "mod+w", keybind: "mod+w",
hidden: true, hidden: true,
onSelect: closeActiveTab, onSelect: () => {
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
},
}, },
{ {
id: `tab.prev`, id: `tab.prev`,
@@ -397,14 +360,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowLeft`, keybind: `mod+option+ArrowLeft`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return if (index === -1) return
index -= 1 index -= 1
if (index === -1) index = tabsStore.length - 1 if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigate(next.href) if (next) navigateTab(next)
}, },
}, },
{ {
@@ -414,14 +377,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowRight`, keybind: `mod+option+ArrowRight`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return if (index === -1) return
index += 1 index += 1
if (index === tabsStore.length) index = 0 if (index === tabsStore.length) index = 0
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigate(next.href) if (next) navigateTab(next)
}, },
}, },
...Array.from({ length: 9 }, (_, i) => { ...Array.from({ length: 9 }, (_, i) => {
@@ -436,31 +399,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
const tab = tabsStore[index] const tab = tabsStore[index]
if (tab) navigate(tab.href) if (tab) navigateTab(tab)
}, },
} }
}), }),
] ].filter((v) => v !== undefined)
return commands
}) })
const tabsEnriched = iife(() => { const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
const base = mapArray( let tabScrollRef!: HTMLDivElement
() => tabsStore,
(tab) => {
const sync = serverSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : [])) function refreshTabsAreOverflowing() {
}) setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
}
return ( return (
<div <div
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 pt-2" class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
classList={{ classList={{
"pl-2": mac(), "pl-2": mac(),
"pl-4": !mac(), "pl-4": !mac(),
@@ -475,34 +430,99 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large" size="large"
as="a" as="a"
href="/" href="/"
class="!w-9" class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />} icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined} state={!!homeMatch() ? "pressed" : undefined}
/> />
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden"> <div
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden"> class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
<For each={tabsEnriched()}> ref={tabScrollRef}
{(tab, i) => ( >
<> <div class="flex min-w-0 flex-row items-center gap-1.5">
{i() !== 0 && ( <For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
onMount(() => {
refreshTabsAreOverflowing()
})
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" /> <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)} )
<TabNavItem
href={tab.href} if (tab.type === "draft") {
title={tab.info.title} return (
project={projectForSession(tab.info, projects(), projectByID())} <>
directory={tab.dir} {divider()}
sessionId={tab.info.id} <DraftTabItem
onClose={() => tabsStoreActions.removeTab(tab.href)} 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 (
<>
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}}
</For> </For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div> </div>
<Show </div>
when={creating() && params.dir} <Show when={!(creating() && params.dir)}>
fallback={
<IconButtonV2 <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
@@ -513,16 +533,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
href={newSessionHref()} href={newSessionHref()}
aria-label={language.t("command.session.new")} aria-label={language.t("command.session.new")}
/> />
}
>
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
/>
</Show> </Show>
<div class="min-w-0 flex-1" /> <div class="flex-1" />
</div>
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}> <Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
@@ -705,66 +717,123 @@ type TitlebarV2RightState = {
function TitlebarV2Right(props: { state: TitlebarV2RightState }) { function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return ( return (
<div class="flex shrink-0 items-center justify-end gap-0"> <div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<TitlebarUpdatePill state={props.state.update} /> <Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" /> <div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div> </div>
) )
} }
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) { function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
return ( return (
<Show when={props.state.visible}> <div class="relative isolate mr-3 size-5 shrink-0">
<button <button
type="button" type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60" class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
onClick={props.state.onInstall} onClick={props.state.onInstall}
disabled={props.state.installing} disabled={props.state.installing}
aria-busy={props.state.installing}
aria-label={props.state.ariaLabel} aria-label={props.state.ariaLabel}
title={props.state.title}
> >
{props.state.label} <span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
</button> Update
</span>
<span class="flex size-5 shrink-0 items-center justify-center">
<Show
when={!props.state.installing}
fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 11V3M3.5 7.63128L7 11L10.5 7.63128" stroke="currentColor" />
</svg>
</Show> </Show>
</span>
</button>
</div>
) )
} }
function TabNavItem(props: { function TabNavItem(props: {
ref?: HTMLDivElement
href: string href: string
title: string server: ServerConnection.Key
project?: LocalProject
directory: string directory: string
sessionId: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
}) { }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return ( return (
<div <div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()} data-active={props.active}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
}} }}
> >
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
<a <a
href={props.href} 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 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
> >
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} /> <ProjectTabAvatar
project={project()}
directory={props.directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span> </span>
<span class="min-w-0 flex-1">{props.title}</span> <span class="min-w-0 flex-1">{session().title}</span>
</a> </a>
)
}}
</Show>
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"> <div
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
data-truncate={props.forceTruncate}
>
<div <div
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)" class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{ style={{
@@ -784,10 +853,15 @@ function TabNavItem(props: {
) )
} }
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) { function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory const directory = () => props.directory
const sessionId = () => props.sessionId const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId) const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return ( return (
<ProjectAvatar <ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })} fallback={displayName(props.project ?? { worktree: props.directory })}
@@ -799,7 +873,14 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string; se
) )
} }
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) { function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -807,7 +888,54 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
} }
return ( return (
<div <div
class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]" 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()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
@@ -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", () => ({ mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => ({}), useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@opencode-ai/ui/context", () => ({ mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({ createSimpleContext: () => ({
+10 -3
View File
@@ -3,6 +3,8 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
@@ -166,11 +168,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
return createCommentSessionState(store, setStore) return createCommentSessionState(store, setStore)
} }
function createCommentSession(dir: string, id: string | undefined) { function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]), Persist.serverScoped(scope, dir, id, "comments", [legacy]),
createStore<CommentStore>({ createStore<CommentStore>({
comments: {}, comments: {},
}), }),
@@ -200,11 +202,16 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const decoded = decodeSessionKey(key) const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), value: createCommentSession(
serverSDK.scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
dispose, dispose,
})) }))
}, },
+15 -8
View File
@@ -8,11 +8,11 @@ import {
getSessionPrefetchPromise, getSessionPrefetchPromise,
setSessionPrefetch, setSessionPrefetch,
} from "./global-sync/session-prefetch" } from "./global-sync/session-prefetch"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk" import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -171,8 +171,11 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
}) })
} }
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => { export const createDirSyncContext = (
const serverSDK = useServerSDK() directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
) => {
const client = serverSDK.createClient({ directory, throwOnError: true }) const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]> type Child = ReturnType<(typeof serverSync)["child"]>
@@ -273,7 +276,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs) clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined) serverSync.todo.set(sessionID, undefined)
} }
@@ -345,6 +348,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
setMeta("cursor", key, next.cursor) setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete) setMeta("complete", key, next.complete)
setSessionPrefetch({ setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory, directory: input.directory,
sessionID: input.sessionID, sessionID: input.sessionID,
limit: message.length, limit: message.length,
@@ -435,7 +439,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID) const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
@@ -446,10 +450,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
} }
return runInflight(inflight, key, async () => { return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID) const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
if (pending) { if (pending) {
await pending await pending
const seeded = getSessionPrefetch(directory, sessionID) const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
@@ -605,6 +609,9 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
) )
}, },
}, },
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute, absolute,
get directory() { get directory() {
return current()[0].path.directory return current()[0].path.directory
+7 -2
View File
@@ -21,6 +21,8 @@ import {
touchFileContent, touchFileContent,
} from "./file/content-cache" } from "./file/content-cache"
import { createFileViewCache } from "./file/view-cache" import { createFileViewCache } from "./file/view-cache"
import { useServerSDK } from "./server-sdk"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { createFileTreeStore } from "./file/tree-store" import { createFileTreeStore } from "./file/tree-store"
import { invalidateFromWatcher } from "./file/watcher" import { invalidateFromWatcher } from "./file/watcher"
import { import {
@@ -56,12 +58,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
useSync() useSync()
const params = useParams() const params = useParams()
const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const layout = useLayout() const layout = useLayout()
const scope = createMemo(() => sdk.directory) const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
)
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -107,7 +112,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
}) })
const viewCache = createFileViewCache() const viewCache = createFileViewCache(serverSDK.scope)
const view = createMemo(() => viewCache.load(scope(), params.id)) const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => { const ensure = (file: string) => {
+5 -4
View File
@@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types" import type { FileViewState, SelectedLineRange } from "./types"
import type { ServerScope } from "@/utils/server-scope"
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20 const MAX_FILE_VIEW_SESSIONS = 20
@@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
) )
} }
function createViewSession(dir: string, id: string | undefined) { function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted( const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]), Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
createStore<{ createStore<{
file: Record<string, FileViewState> file: Record<string, FileViewState>
}>({ }>({
@@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) {
} }
} }
export function createFileViewCache() { export function createFileViewCache(scope: ServerScope) {
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const split = key.lastIndexOf("\n") const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id),
dispose, dispose,
})) }))
}, },
@@ -3,8 +3,9 @@ import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap" import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({ await bootstrapDirectory({
directory: "/project", directory: "/project",
scope: ServerScope.local,
mcp: false, mcp: false,
global: { global: {
config: {} satisfies Config, config: {} satisfies Config,
@@ -89,3 +91,18 @@ describe("bootstrapDirectory", () => {
expect(mcpReads).toEqual([]) expect(mcpReads).toEqual([])
}) })
}) })
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as OpencodeClient
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
"https://debian.example",
null,
"providers",
])
})
})
@@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync" import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
const providerRev = new Map<string, number>() const providerRev = new Map<string, number>()
export function clearProviderRev(directory: string) { export function clearProviderRev(scope: ServerScope, directory: string) {
providerRev.delete(directory) providerRev.delete(ScopedKey.from(scope, directory))
} }
function runAll(list: Array<() => Promise<unknown>>) { function runAll(list: Array<() => Promise<unknown>>) {
@@ -83,15 +84,15 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (sdk: OpencodeClient) => export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: ["config"], queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
}) })
export const loadProjectsQuery = (sdk: OpencodeClient) => export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: ["project"], queryKey: [scope, "project"],
queryFn: () => queryFn: () =>
retry(() => retry(() =>
sdk.project.list().then((x) => { sdk.project.list().then((x) => {
@@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient serverSDK: OpencodeClient
scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.serverSDK)) .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ]
await runAll(slow) await runAll(slow)
@@ -178,26 +180,27 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) => export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "providers"], queryKey: [scope, directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
}) })
export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) => export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "agents"], queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
}) })
export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) => export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [directory, "path"], queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
}) })
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope
mcp: boolean mcp: boolean
sdk: OpencodeClient sdk: OpencodeClient
store: Store<State> store: Store<State>
@@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: {
} }
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1 const revKey = ScopedKey.from(input.scope, input.directory)
providerRev.set(input.directory, rev) const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
;(async () => { ;(async () => {
const slow = [ const slow = [
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient input.queryClient
.ensureQueryData(loadAgentsQuery(input.directory, input.sdk)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: {
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath && !seededPath &&
(() => (() =>
input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => { input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project) const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
})), })),
@@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
@@ -4,9 +4,16 @@ import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types" import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
null,
Object.assign(() => true, { promise: undefined }),
]
const child = () => createStore({} as State) const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) {
} }
beforeAll(async () => { beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({ mock.module("@tanstack/solid-query", () => ({
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => { useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options) querySingles.push(options)
@@ -80,6 +81,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
scope: ServerScope.local,
persist,
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
@@ -109,6 +112,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
scope: ServerScope.local,
persist,
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap(directory) { onBootstrap(directory) {
@@ -128,6 +133,7 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/project") const [store] = manager.child("/project")
expect(store.status).toBe("loading") expect(store.status).toBe("loading")
expect(store.limit).toBe(5)
expect(bootstraps).toEqual(["/project"]) expect(bootstraps).toEqual(["/project"])
} finally { } finally {
dispose() dispose()
@@ -140,6 +146,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
scope: ServerScope.local,
persist,
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
@@ -171,6 +179,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
scope: ServerScope.local,
persist,
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
@@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils" import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
@@ -147,8 +150,8 @@ export function createChildStoreManager(input: {
if (!key) console.error("No directory provided") if (!key) console.error("No directory provided")
if (!children[key]) { if (!children[key]) {
const vcs = runWithOwner(input.owner, () => const vcs = runWithOwner(input.owner, () =>
persisted( input.persist(
Persist.workspace(directory, "vcs", ["vcs.v1"]), Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }), createStore({ value: undefined as VcsInfo | undefined }),
), ),
) )
@@ -157,8 +160,8 @@ export function createChildStoreManager(input: {
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () => const meta = runWithOwner(input.owner, () =>
persisted( input.persist(
Persist.workspace(directory, "project", ["project.v1"]), Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
createStore({ value: undefined as ProjectMeta | undefined }), createStore({ value: undefined as ProjectMeta | undefined }),
), ),
) )
@@ -166,8 +169,8 @@ export function createChildStoreManager(input: {
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () => const icon = runWithOwner(input.owner, () =>
persisted( input.persist(
Persist.workspace(directory, "icon", ["icon.v1"]), Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }), createStore({ value: undefined as string | undefined }),
), ),
) )
@@ -134,6 +134,27 @@ describe("applyGlobalEvent", () => {
}) })
describe("applyDirectoryEvent", () => { 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", () => { test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({
@@ -99,8 +99,10 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void loadLsp: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
}) { }) {
const event = input.event const event = input.event
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) { switch (event.type) {
case "server.instance.disposed": { case "server.instance.disposed": {
input.push(input.directory) input.push(input.directory)
@@ -115,7 +117,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) 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" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
@@ -145,7 +147,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) 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" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break 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()
}
@@ -7,14 +7,18 @@ import {
setSessionPrefetch, setSessionPrefetch,
shouldSkipSessionPrefetch, shouldSkipSessionPrefetch,
} from "./session-prefetch" } from "./session-prefetch"
import { ServerScope } from "@/utils/server-scope"
const scope = ServerScope.local
describe("session prefetch", () => { describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => { test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"]) clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"]) clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
setSessionPrefetch({ setSessionPrefetch({
directory: "/tmp/a", directory: "/tmp/a",
scope,
sessionID: "ses_1", sessionID: "ses_1",
limit: 200, limit: 200,
cursor: "abc", cursor: "abc",
@@ -22,21 +26,27 @@ describe("session prefetch", () => {
at: 123, at: 123,
}) })
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 }) expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined() limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"]) clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined() expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
}) })
test("dedupes inflight work", async () => { test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"]) clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
let calls = 0 let calls = 0
const run = () => const run = () =>
runSessionPrefetch({ runSessionPrefetch({
directory: "/tmp/c", directory: "/tmp/c",
scope,
sessionID: "ses_2", sessionID: "ses_2",
task: async () => { task: async () => {
calls += 1 calls += 1
@@ -52,15 +62,48 @@ describe("session prefetch", () => {
}) })
test("clears a whole directory", () => { test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 }) setSessionPrefetch({
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 }) scope,
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 }) directory: "/tmp/d",
sessionID: "ses_1",
limit: 10,
cursor: "a",
complete: true,
at: 1,
})
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_2",
limit: 20,
cursor: "b",
complete: false,
at: 2,
})
setSessionPrefetch({
scope,
directory: "/tmp/e",
sessionID: "ses_1",
limit: 30,
cursor: "c",
complete: true,
at: 3,
})
clearSessionPrefetchDirectory("/tmp/d") clearSessionPrefetchDirectory(scope, "/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined() expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined() expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 }) expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("isolates identical directories and sessions by server scope", () => {
const remote = "https://debian.example" as ServerScope
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
}) })
test("refreshes stale first-page prefetched history", () => { test("refreshes stale first-page prefetched history", () => {
@@ -1,4 +1,6 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` import { ScopedKey, type ServerScope } from "@/utils/server-scope"
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
export const SESSION_PREFETCH_TTL = 15_000 export const SESSION_PREFETCH_TTL = 15_000
@@ -27,28 +29,32 @@ const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0 const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) { export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
return cache.get(key(directory, sessionID)) return cache.get(key(scope, directory, sessionID))
} }
export function getSessionPrefetchPromise(directory: string, sessionID: string) { export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID)) return inflight.get(key(scope, directory, sessionID))
} }
export function clearSessionPrefetchInflight() { export function clearSessionPrefetchInflight(scope: ServerScope) {
inflight.clear() const prefix = ScopedKey.prefix(scope)
for (const id of inflight.keys()) {
if (id.startsWith(prefix)) inflight.delete(id)
}
} }
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value return version(key(scope, directory, sessionID)) === value
} }
export function runSessionPrefetch(input: { export function runSessionPrefetch(input: {
directory: string directory: string
scope: ServerScope
sessionID: string sessionID: string
task: (value: number) => Promise<Meta | undefined> task: (value: number) => Promise<Meta | undefined>
}) { }) {
const id = key(input.directory, input.sessionID) const id = key(input.scope, input.directory, input.sessionID)
const pending = inflight.get(id) const pending = inflight.get(id)
if (pending) return pending if (pending) return pending
@@ -64,13 +70,14 @@ export function runSessionPrefetch(input: {
export function setSessionPrefetch(input: { export function setSessionPrefetch(input: {
directory: string directory: string
scope: ServerScope
sessionID: string sessionID: string
limit: number limit: number
cursor?: string cursor?: string
complete: boolean complete: boolean
at?: number at?: number
}) { }) {
cache.set(key(input.directory, input.sessionID), { cache.set(key(input.scope, input.directory, input.sessionID), {
limit: input.limit, limit: input.limit,
cursor: input.cursor, cursor: input.cursor,
complete: input.complete, complete: input.complete,
@@ -78,18 +85,18 @@ export function setSessionPrefetch(input: {
}) })
} }
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) { export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
if (!sessionID) continue if (!sessionID) continue
const id = key(directory, sessionID) const id = key(scope, directory, sessionID)
rev.set(id, version(id) + 1) rev.set(id, version(id) + 1)
cache.delete(id) cache.delete(id)
inflight.delete(id) inflight.delete(id)
} }
} }
export function clearSessionPrefetchDirectory(directory: string) { export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
const prefix = `${directory}\n` const prefix = ScopedKey.prefix(scope, directory)
const keys = new Set([...cache.keys(), ...inflight.keys()]) const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) { for (const id of keys) {
if (!id.startsWith(prefix)) continue if (!id.startsWith(prefix)) continue
+151
View File
@@ -0,0 +1,151 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, ServerConnection, useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const serverHealth = useServerHealth(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
return root.serverCtx
}
createMemo(() => {
for (const conn of server.list) {
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
const { dispose } = serverCtxs.get(key)!
dispose()
serverCtxs.delete(key)
}
}
})
return {
servers: {
list: () => server.list,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
createServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},
}
},
})
function createServerCtx(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? sync.data.project.find((x) => x.id === projectID)
: sync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
}
const projectsList = createMemo(() => projects.list().map(enrich))
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
projects: {
...projects,
list: projectsList,
},
}
}
export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
+1
View File
@@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
gate: false,
init: (props: { locale?: Locale }) => { init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale() const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@@ -0,0 +1,38 @@
import type { Accessor } from "solid-js"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js" import { createRoot, createSignal } from "solid-js"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
describe("layout session-key helpers", () => { describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => { test("couples touch and scroll seed in order", () => {
+57 -46
View File
@@ -1,10 +1,11 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -13,6 +14,10 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path" import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
export type { ProjectAvatarVariant } export type { ProjectAvatarVariant }
@@ -60,6 +65,7 @@ type SessionView = {
} }
type TabHandoff = { type TabHandoff = {
scope: ServerScope
dir: string dir: string
id: string id: string
at: number at: number
@@ -69,42 +75,11 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { export type LayoutRoute =
touch(key) | { type: "home" }
seed(key) | { type: "draft"; draftID: string; server?: ServerConnection.Key }
return key | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
} | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@@ -115,7 +90,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
} }
const sessionPath = (key: string) => { const sessionPath = (key: string) => {
const dir = key.split("/")[0] const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return if (!dir) return
const root = decode64(dir) const root = decode64(dir)
if (!root) return if (!root) return
@@ -146,13 +121,41 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
} }
} }
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" }
if (parts[1] !== "session") return { type: "home" }
const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
gate: false,
init: () => { init: () => {
const globalSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
return { ...value, server: server.key }
})
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) typeof value === "object" && value !== null && !Array.isArray(value)
@@ -197,7 +200,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
})() })()
const sessionTabs = value.sessionTabs const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
const sessionView = migrateLegacySessionStateKeys(value.sessionView)
const migratedSessionTabs = (() => { const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs if (!isRecord(sessionTabs)) return sessionTabs
@@ -226,7 +230,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
migratedSidebar === sidebar && migratedSidebar === sidebar &&
migratedReview === review && migratedReview === review &&
migratedFileTree === fileTree && migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs migratedSessionTabs === value.sessionTabs &&
sessionView === value.sessionView
) { ) {
return value return value
} }
@@ -237,10 +242,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: migratedReview, review: migratedReview,
fileTree: migratedFileTree, fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs, sessionTabs: migratedSessionTabs,
sessionView,
} }
} }
const target = Persist.global("layout", ["layout.v6"]) const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ ...target, migrate }, { ...target, migrate },
createStore({ createStore({
@@ -293,15 +299,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const dropSessionState = (keys: string[]) => { const dropSessionState = (keys: string[]) => {
for (const key of keys) { for (const key of keys) {
const parts = key.split("/") const scope = SessionStateKey.scope(key)
const parts = SessionStateKey.route(key).split("/")
const dir = parts[0] const dir = parts[0]
const session = parts[1] const session = parts[1]
if (!dir) continue if (!dir) continue
for (const entry of SESSION_STATE_KEYS) { for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) const target = session
? Persist.serverSession(scope, dir, session, entry.key)
: Persist.serverWorkspace(scope, dir, entry.key)
void removePersisted(target, platform) void removePersisted(target, platform)
if (scope !== ServerScope.local) continue
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform) void removePersisted({ key: legacyKey }, platform)
} }
@@ -526,7 +536,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
continue continue
} }
void globalSdk.client.project void serverSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } }) .update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => { .catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
@@ -557,11 +567,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}) })
return { return {
route,
ready, ready,
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return
+9 -6
View File
@@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string } export type ModelKey = { providerID: string; modelID: string; variant?: string }
@@ -25,7 +27,7 @@ type Saved = {
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>() const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}` const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => { const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} } if (!value || typeof value !== "object") return { session: {} }
@@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSDK = useServerSDK()
const providers = useProviders() const providers = useProviders()
const models = useModels() const models = useModels()
@@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved] = persisted( const [saved, setSaved] = persisted(
{ {
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]), ...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
migrate, migrate,
}, },
createStore<Saved>({ createStore<Saved>({
@@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => { const scope = createMemo<State | undefined>(() => {
const session = id() const session = id()
if (!session) return store.draft if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session)) return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
}) })
createEffect(() => { createEffect(() => {
const session = id() const session = id()
if (!session) return if (!session) return
const key = handoffKey(sdk.directory, session) const key = handoffKey(serverSDK.scope, sdk.directory, session)
const next = handoff.get(key) const next = handoff.get(key)
if (!next) return if (!next) return
if (saved.session[session] !== undefined) { if (saved.session[session] !== undefined) {
@@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return return
} }
handoff.set(handoffKey(dir, session), next) handoff.set(handoffKey(serverSDK.scope, dir, session), next)
setStore("draft", undefined) setStore("draft", undefined)
}, },
restore(msg: { sessionID: string; agent: string; model: ModelKey }) { restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return if (!session) return
if (msg.sessionID !== session) return if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
setSaved("session", session, { setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
+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),
}),
}))
}
+1
View File
@@ -24,6 +24,7 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models", name: "Models",
gate: false,
init: () => { init: () => {
const providers = useProviders() const providers = useProviders()
+2 -1
View File
@@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id) const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]), Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
createStore({ createStore({
list: [] as Notification[], list: [] as Notification[],
}), }),
+2 -1
View File
@@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ {
...Persist.global("permission", ["permission.v3"]), ...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
migrate(value) { migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value if (!value || typeof value !== "object" || Array.isArray(value)) return value
+30 -25
View File
@@ -3,12 +3,19 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu" import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } 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 SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop" type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux" type DesktopOS = "macos" | "windows" | "linux"
@@ -20,13 +27,7 @@ export type FatalRendererErrorLog = {
os?: DesktopOS os?: DesktopOS
} }
export type Platform = { type PlatformBase = {
/** Platform discriminator */
platform: PlatformName
/** Desktop OS (Tauri only) */
os?: DesktopOS
/** App version */ /** App version */
version?: string version?: string
@@ -48,23 +49,20 @@ export type Platform = {
/** Send a system notification (optional deep link) */ /** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void> notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */ /** Open a native attachment picker and read selected files sequentially (desktop only) */
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths> openAttachmentPickerDialog?(
opts: OpenAttachmentPickerOptions,
onFile: (file: File) => Promise<unknown>,
): Promise<void>
/** Open native file picker dialog (Tauri only) */ /** Open a native save file picker dialog (desktop only) */
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null> saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */ /** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for a downloadable desktop update */ /** Application-global desktop updater */
checkUpdate?(): Promise<UpdateInfo> updater?: UpdaterPlatform
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Fetch override */ /** Fetch override */
fetch?: typeof fetch fetch?: typeof fetch
@@ -75,11 +73,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */ /** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */ /** Manage WSL sidecar servers (Electron on Windows only) */
getWslEnabled?(): Promise<boolean> wslServers?: WslServersPlatform
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */ /** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
@@ -115,6 +110,16 @@ export type Platform = {
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void> 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 type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+24 -12
View File
@@ -1,10 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode" 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 { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase { interface PartBase {
content: string content: string
@@ -151,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession> type PromptSession = ReturnType<typeof createPromptSession>
type Scope = { type Scope = { draftID: string } | { dir: string; id?: 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 = { type PromptCacheEntry = {
@@ -161,11 +165,15 @@ type PromptCacheEntry = {
dispose: VoidFunction dispose: VoidFunction
} }
function createPromptSession(dir: string, id: string | undefined) { function promptTarget(serverScope: ServerScope, scope: Scope) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` 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( const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]), promptTarget(serverScope, scope),
createStore<{ createStore<{
prompt: Prompt prompt: Prompt
cursor?: number cursor?: number
@@ -229,6 +237,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => { const disposeAll = () => {
@@ -251,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const owner = getOwner() const owner = getOwner()
const load = (dir: string, id: string | undefined) => { const load = (scope: Scope) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}` const key = scopeKey(scope)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
cache.delete(key) cache.delete(key)
@@ -262,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot( const entry = createRoot(
(dispose) => ({ (dispose) => ({
value: createPromptSession(dir, id), value: createPromptSession(serverSDK.scope, scope),
dispose, dispose,
}), }),
owner, owner,
@@ -273,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value return entry.value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() =>
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session()) load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
return { return {
ready: () => session().ready, ready: () => session().ready,
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
let starts = 0
const start = () => starts++
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
expect(starts).toBe(1)
})
})
+38 -16
View File
@@ -8,11 +8,20 @@ import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) { const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
@@ -95,11 +104,10 @@ function createServerSdkContext(server: ServerConnection.Any) {
let streamErrorLogged = false let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined let attempt: AbortController | undefined
let run: Promise<void> | undefined let run: Promise<void> | undefined
let started = false let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000 const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now() let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -119,9 +127,12 @@ function createServerSdkContext(server: ServerConnection.Any) {
const start = () => { const start = () => {
if (started) return run if (started) return run
started = true started = true
run = (async () => { const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) { while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController() attempt = new AbortController()
lastEventAt = Date.now() lastEventAt = Date.now()
const onAbort = () => { const onAbort = () => {
@@ -132,7 +143,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
const events = await eventSdk.global.event({ const events = await eventSdk.global.event({
signal: attempt.signal, signal: attempt.signal,
onSseError: (error) => { onSseError: (error) => {
if (aborted(error)) return if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return if (streamErrorLogged) return
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream error", { console.error("[global-sdk] event stream error", {
@@ -175,7 +186,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
await wait(0) await wait(0)
} }
} catch (error) { } catch (error) {
if (!aborted(error) && !streamErrorLogged) { if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: server.http.url, url: server.http.url,
@@ -189,23 +200,28 @@ function createServerSdkContext(server: ServerConnection.Any) {
clearHeartbeat() clearHeartbeat()
} }
if (abort.signal.aborted || !started) return if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS) await wait(RECONNECT_DELAY_MS)
} }
})().finally(() => { })().finally(() => {
if (run !== current) return
run = undefined run = undefined
flush() flush()
}) })
run = current
return run return run
} }
const stop = () => { const stop = () => {
started = false started = false
generation++
attempt?.abort() attempt?.abort()
clearHeartbeat() clearHeartbeat()
} }
onMount(() => { onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => { makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return if (document.visibilityState !== "visible") return
if (!started) return if (!started) return
@@ -227,6 +243,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
}) })
return { return {
scope,
url: server.http.url, url: server.http.url,
client: sdk, client: sdk,
event: { event: {
@@ -244,18 +261,22 @@ function createServerSdkContext(server: ServerConnection.Any) {
} }
} }
export type ServerSDK = ReturnType<typeof createServerSdkContext>
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK", name: "ServerSDK",
init: () => { init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable")) const conn = props.server ?? server.current
const sdk = createServerSdkContext(server.current) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return {
...sdk, const ctx = global.createServerCtx(conn)
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), return Object.assign(ctx.sdk, {
} createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
})
}, },
}) })
@@ -263,7 +284,7 @@ type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
} }
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) { function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
const client = serverSDK.createClient({ const client = serverSDK.createClient({
directory, directory,
throwOnError: true, throwOnError: true,
@@ -277,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof cre
onCleanup(unsub) onCleanup(unsub)
return { return {
scope: serverSDK.scope,
directory, directory,
client, client,
event: emitter, event: emitter,
+96 -41
View File
@@ -1,11 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { useServerSDK } from "./server-sdk" import { ServerSDK, useServerSDK } from "./server-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
@@ -31,7 +31,12 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry" 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 = { type GlobalStore = {
ready: boolean ready: boolean
@@ -47,35 +52,40 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "mcp"] as const, queryKey: [scope, directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
}) })
export const loadLspQuery = (directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
}) })
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
return { return {
globalConfig: () => loadGlobalConfigQuery(serverSDK()), globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(serverSDK()), projects: () => loadProjectsQuery(scope, serverSDK()),
providers: (directory: PathKey | null) => providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)), loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)), path: (directory: PathKey | null) =>
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)), loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
} }
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi> export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext() { export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const serverSDK = useServerSDK() const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
const language = useLanguage() const language = useLanguage()
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner") if (!owner) throw new Error("ServerSync must be created within owner")
@@ -97,7 +107,7 @@ export function createServerSyncContext() {
return sdk return sdk
} }
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -105,7 +115,7 @@ export function createServerSyncContext() {
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() { get ready() {
return bootstrap.isPending return !bootstrap.isPending
}, },
project: [], project: [],
session_todo: {}, session_todo: {},
@@ -128,6 +138,7 @@ export function createServerSyncContext() {
return updateConfigMutation.isPending ? "pending" : undefined return updateConfigMutation.isPending ? "pending" : undefined
}, },
}) })
const queryClient = useQueryClient() const queryClient = useQueryClient()
let bootedAt = 0 let bootedAt = 0
@@ -153,10 +164,11 @@ export function createServerSyncContext() {
}) as typeof setGlobalStore }) as typeof setGlobalStore
const bootstrap = useQuery(() => ({ const bootstrap = useQuery(() => ({
queryKey: ["bootstrap"], queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => { queryFn: async () => {
await bootstrapGlobal({ await bootstrapGlobal({
serverSDK: serverSDK.client, serverSDK: serverSDK.client,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -195,12 +207,14 @@ export function createServerSyncContext() {
const queue = createRefreshQueue({ const queue = createRefreshQueue({
paused, paused,
key: directoryKey, key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
bootstrapInstance, bootstrapInstance,
}) })
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory), isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => { onBootstrap: (directory) => {
@@ -224,8 +238,8 @@ export function createServerSyncContext() {
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key) sdkCache.delete(key)
clearProviderRev(key) clearProviderRev(serverSDK.scope, key)
clearSessionPrefetchDirectory(key) clearSessionPrefetchDirectory(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -234,17 +248,21 @@ export function createServerSyncContext() {
}, },
}) })
async function loadSessions(directory: string) { async function loadSessions(directory: string, options?: { limit?: number }) {
const key = directoryKey(directory) const key = directoryKey(directory)
const pending = sessionLoads.get(key) const pending = sessionLoads.get(key)
if (pending) return pending if (pending) {
await pending
return loadSessions(directory, options)
}
children.pin(key) children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key) 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, { const next = trimSessions(store.session, {
limit: store.limit, limit: retainedLimit,
permission: store.permission, permission: store.permission,
}) })
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
@@ -255,7 +273,7 @@ export function createServerSyncContext() {
return 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 const promise = queryClient
.fetchQuery({ .fetchQuery({
...queryOptionsApi.sessions(key), ...queryOptionsApi.sessions(key),
@@ -270,7 +288,7 @@ export function createServerSyncContext() {
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
.filter((s) => !s.time?.archived) .filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .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 childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { const sessions = trimSessions([...nonArchived, ...childSessions], {
limit, limit,
@@ -325,6 +343,7 @@ export function createServerSyncContext() {
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope,
mcp: children.mcp(key), mcp: children.mcp(key),
global: { global: {
config: globalStore.config, config: globalStore.config,
@@ -386,6 +405,7 @@ export function createServerSyncContext() {
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo, setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
@@ -436,8 +456,10 @@ export function createServerSyncContext() {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers // Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories. // appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] }) queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" }) queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
}, },
})) }))
@@ -460,22 +482,55 @@ export function createServerSyncContext() {
todo: { todo: {
set: setSessionTodo, 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))
},
})
},
},
} }
} }
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const inner = createServerSyncContextInner(_serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, _serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
}
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync", name: "ServerSync",
init: () => { gate: false,
const sync = createServerSyncContext() init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return { const conn = props.server ?? server.current
...sync, if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
createDirSyncContext: createRefCountMap( const ctx = global.createServerCtx(conn)
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir), return ctx.sync
directoryKey,
),
}
}, },
}) })
+111 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server" import { createRoot, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerProjects,
migrateCanonicalLocalServerState,
nextServerAfterRemoval,
resolveServerList,
ServerConnection,
} from "./server"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => { describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => { test("lets startup auth_token credentials override a persisted same-url server", () => {
@@ -51,3 +60,104 @@ describe("resolveServerList", () => {
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
}) })
}) })
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) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
remote.open("/repo")
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
expect(active.list()).toEqual([])
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
adopted.close("/repo")
expect(remote.list()).toEqual([])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
migrateCanonicalLocalServerState(
{
list: [],
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
lastProject: { "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
list: [],
projects: { local: [{ worktree: "/remote", expanded: true }] },
lastProject: { local: "/remote" },
})
})
test("preserves existing local state while merging a canonical web bucket", () => {
expect(
migrateCanonicalLocalServerState(
{
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
projects: {
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local" },
})
})
})
+128 -74
View File
@@ -1,10 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js" import { type Accessor, batch, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
const HEALTH_POLL_INTERVAL_MS = 10_000 const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) { export function normalizeServerUrl(input: string) {
@@ -20,18 +22,95 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) { function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0] const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local" if (host === "localhost" || host === "127.0.0.1") return "local"
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
scope: Accessor<ServerScope>
store: Store<T>
setStore: SetStoreFunction<T>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
return {
list: current,
open(directory: string) {
const scope = input.scope()
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
close(directory: string) {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
},
collapse(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
},
move(directory: string, toIndex: number) {
const fromIndex = current().findIndex((project) => project.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const next = [...current()]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
setStore("projects", input.scope(), next)
},
last() {
return input.store.lastProject[input.scope()]
},
touch(directory: string) {
setStore("lastProject", input.scope(), directory)
},
}
}
export function resolveServerList(input: { export function resolveServerList(input: {
props?: Array<ServerConnection.Any> props?: Array<ServerConnection.Any>
stored: StoredServer[] stored: StoredServer[]
@@ -66,7 +145,7 @@ export function resolveServerList(input: {
} }
export namespace ServerConnection { export namespace ServerConnection {
type Base = { displayName?: string } type Base = { displayName?: string; label?: string }
export type HttpBase = { export type HttpBase = {
url: string url: string
@@ -123,13 +202,35 @@ export namespace ServerConnection {
export type Key = string & { _brand: "Key" } export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as 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({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { gate: true,
init: (props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), {
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({ createStore({
list: [] as StoredServer[], list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
@@ -168,27 +269,30 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
function remove(key: ServerConnection.Key) { function remove(key: ServerConnection.Key) {
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
const list = store.list.filter((x) => url(x) !== key) const list = store.list.filter((x) => url(x) !== key)
batch(() => { batch(() => {
setStore("list", list) setStore("list", list)
if (state.active === key) { if (state.active === key) setState("active", next)
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
}) })
} }
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const origin = createMemo(() => projectsKey(state.active)) const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projects = createServerProjects({ scope, store, setStore })
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
projectStores.set(key, next)
return next
}
const current: Accessor<ServerConnection.Any | undefined> = createMemo( const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
) )
const isLocal = createMemo(() => { const isLocal = createMemo(() => ServerConnection.local(current()))
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
return { return {
ready: isReady, ready: isReady,
@@ -208,60 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive, setActive,
add, add,
remove, remove,
scope,
projects: { projects: {
list: projectsList, ...projects,
open(directory: string) { forServer: projectsForServer,
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
last() {
const key = origin()
if (!key) return
return store.lastProject[key]
},
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
},
}, },
} }
}, },
-20
View File
@@ -1,20 +0,0 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Servers",
init: () => {
const server = useServer()
const health = useServerHealth(
() => server.list,
() => true,
)
return {
list: () => server.list,
health,
}
},
})
+1 -12
View File
@@ -35,9 +35,6 @@ export interface Settings {
showCustomAgents: boolean showCustomAgents: boolean
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
} }
updates: {
startup: boolean
}
appearance: { appearance: {
fontSize: number fontSize: number
mono: string mono: string
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
showSessionProgressBar: true, showSessionProgressBar: true,
showCustomAgents: false, showCustomAgents: false,
}, },
updates: {
startup: true,
},
appearance: { appearance: {
fontSize: 14, fontSize: 14,
mono: "", mono: "",
@@ -156,6 +150,7 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings", name: "Settings",
gate: false,
init: () => { init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings)) const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
@@ -249,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newLayoutDesigns", value) setStore("general", "newLayoutDesigns", value)
}, },
}, },
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: { appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) { setFontSize(value: number) {
+216
View File
@@ -0,0 +1,216 @@
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, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
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 = {
type: "session"
server: ServerConnection.Key
dirBase64: string
sessionId: string
}
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
}
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(
{
...Persist.global("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
},
createStore<Tab[]>([]),
)
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))
if (store.every((tab) => servers.has(tab.server))) return
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
},
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(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
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) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
: undefined
const currentIndex = currentHref
? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab =
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigateTab(nextTab)
else navigate("/")
}),
)
})
},
}
return { ...actions, store, ready }
},
})

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