Compare commits

..

491 Commits

Author SHA1 Message Date
opencode-agent[bot] 2b9cb5030f Apply PR #30303: feat(tui): add session render state dump 2026-06-02 03:39:04 +00:00
opencode-agent[bot] 971e796d43 Apply PR #29948: fix(tui): keep command palette available in questions 2026-06-02 03:39:03 +00:00
opencode-agent[bot] 2b809a3b6d Apply PR #28071: feat: add well-known auth service 2026-06-02 03:39:03 +00:00
opencode-agent[bot] e089226bfd Apply PR #27805: Discover running serve instances from TUI 2026-06-02 03:32:16 +00:00
opencode-agent[bot] 0311333612 Apply PR #26596: feat(tui): expand pasted summaries on click 2026-06-02 03:32:15 +00:00
opencode-agent[bot] 0849e327f9 Apply PR #26387: tui: optimistically render submitted prompts 2026-06-02 03:32:15 +00:00
opencode-agent[bot] 887bb5b49c Apply PR #23407: feat: desktop WSL onboarding + happy experience 2026-06-02 03:30:35 +00:00
opencode-agent[bot] bf8503fe43 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-06-02 03:27:39 +00:00
opencode-agent[bot] e75ed98ee3 Apply PR #11710: feat: Add the ability to include cleared prompts in the history, toggled by a KV-persisted command palette item (resolves #11489) 2026-06-02 03:23:50 +00:00
Aiden Cline 4668db8fa2 fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) 2026-06-01 22:20:44 -05:00
Luke Parker ab69f41067 fix(app): avoid suspending on pending child path (#30314) 2026-06-02 03:19:56 +00:00
opencode-agent[bot] 687c66248c chore: update nix node_modules hashes 2026-06-02 02:54:52 +00:00
Luke Parker 61b5046f88 fix: export v2 stylesheets and declare core node types (#30312) 2026-06-02 12:37:50 +10:00
Luke Parker 4e70eabbcc fix(app): restore deferred MCP status updates (#30220) 2026-06-02 12:11:11 +10:00
Kit Langton 1cae8f89c5 fix(tui): preserve live parts during session hydration (#30300) 2026-06-01 21:40:29 -04:00
opencode-agent[bot] 8dc2ffd48f chore: generate 2026-06-02 01:34:11 +00:00
Dax 9b815bcbd2 feat(core): add location-based permission service (#30287) 2026-06-02 01:32:50 +00:00
opencode-agent[bot] acd620f411 chore: generate 2026-06-02 00:56:03 +00:00
Kit Langton 7e71e37a9b feat(tui): add session render state dump 2026-06-01 20:55:24 -04:00
Luke Parker f0c7febb02 fix(opencode): enforce storage path invariants (#29666) 2026-06-02 00:54:41 +00:00
opencode-agent[bot] a821029258 chore: generate 2026-06-01 21:42:43 +00:00
Kit Langton 57d602d07a fix(tui): keep retry attempt before message (#30275) 2026-06-01 21:41:21 +00:00
Kit Langton 6072a68d6b fix(tui): keep background marker with subagent label (#30271) 2026-06-01 15:56:15 -04:00
Aiden Cline ae92f3158f feat(core): update Copilot for token-based billing (#30181) 2026-06-01 14:55:23 -05:00
James Long fa23fb5d38 fix(tui): handle events across workspaces (#30281) 2026-06-01 19:47:37 +00:00
Kit Langton 5f2b4eab28 fix(tui): clarify inline subagent rows (#30051) 2026-06-01 15:44:11 -04:00
opencode-agent[bot] 32d5058f8c chore: generate 2026-06-01 19:10:46 +00:00
Aarav Sareen 363d6d1a26 feat(app): v2 desktop UI improvements (#29689)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-01 19:09:09 +00:00
Adam 2bf85b8479 fix(stats): align big-pickle provider resolution (#30274) 2026-06-01 14:09:07 -05:00
Adam 1813256d8e fix(stats): stabilize top models hover 2026-06-01 12:52:11 -05:00
Adam d7ba8b1125 fix(stats): center top models dot grid 2026-06-01 12:45:58 -05:00
Adam 44350bced9 fix(stats): restore leaderboard spacing 2026-06-01 11:48:59 -05:00
opencode-agent[bot] 913c4ef0b1 chore: update nix node_modules hashes 2026-06-01 13:34:43 +00:00
opencode-agent[bot] 10095bfad4 chore: generate 2026-06-01 13:13:19 +00:00
Simon Klee 6a3b2f339a add run --replay mode (#30239) 2026-06-01 15:11:45 +02:00
Shoubhit Dash d85f8cd4d8 fix(core): contain lsp warmup defects (#30226) 2026-06-01 16:08:23 +05:30
opencode-agent[bot] 68676f2c5c chore: generate 2026-06-01 08:47:26 +00:00
Shoubhit Dash 2d2f587bfa fix(opencode): avoid nullable webfetch format schema (#30215) 2026-06-01 14:15:57 +05:30
Orca丶 bba76009a8 fix(tui): prevent prompt corruption when pasting near wide characters (#29710)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-06-01 08:42:14 +00:00
smagnuso 50b4ad89b3 fix(acp): honor session/cancel by aborting the running turn (#30145)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-01 14:08:28 +05:30
opencode-agent[bot] fd2278eefc chore: generate 2026-06-01 07:27:27 +00:00
Simon Klee 7ccb7889af opencode(run): add queued prompt management (#30103)
Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.
2026-06-01 09:26:09 +02:00
Aiden Cline f9ba23ab62 refactor(session): align namespace imports and inline trivial helpers (#30180) 2026-06-01 00:00:40 -05:00
Aiden Cline b258a55a6c fix(core): preserve session metadata migration identity (#30176) 2026-06-01 04:50:43 +00:00
Michael Hart 2725eed992 fix(app): show project sessions before path sync resolves (#30167)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-01 14:27:03 +10:00
Aiden Cline a9c115c220 refactor(opencode): simplify provider setup flow (#30173) 2026-05-31 23:17:57 -05:00
Aiden Cline 917a36abc3 refactor(opencode): inline local provider helpers (#30169) 2026-05-31 22:59:44 -05:00
Frank c57379833e go: minimax m3 2026-05-31 15:20:55 -04:00
opencode-agent[bot] abaabdcb73 fix(tui): remount session view on session switch (#30129)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-05-31 18:30:29 +00:00
Frank 8f2afba7a5 zen: deepseek flash 2026-05-31 14:01:12 -04:00
Dax Raad 31f94f205e refactor(worktree): move project out of repository 2026-05-31 14:00:01 -04:00
opencode-agent[bot] 542b082373 chore: generate 2026-05-31 17:35:31 +00:00
Dax 2f2fcc1654 fix(opencode): remove automatic full session diffs (#30127) 2026-05-31 17:34:14 +00:00
Dax Raad a69b70d5ca fix(core): allow skipping migration execution 2026-05-31 12:17:40 -04:00
Dax Raad 02edad83b2 test(tui): skip crashing keymap textarea renderer 2026-05-31 12:06:03 -04:00
Dax 5661af2034 feat(worktree): add managed workspace cloning (#30117) 2026-05-31 11:57:44 -04:00
Shoubhit Dash 331bed2469 fix(core): stabilize migration registry generation (#30105) 2026-05-31 19:28:09 +05:30
opencode-agent[bot] f401f01c05 chore: update nix node_modules hashes 2026-05-31 09:18:58 +00:00
opencode-agent[bot] 6f07f10dd2 chore: generate 2026-05-31 08:59:27 +00:00
Simon Klee e8dd8f7fe0 tui(run): use keymap instead of raw key events (#30077)
Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
2026-05-31 10:58:12 +02:00
opencode-agent[bot] a291967206 chore: generate 2026-05-31 07:27:16 +00:00
Dax Raad a893ca857a sync 2026-05-31 03:25:47 -04:00
Dax Raad 25edeaf473 fix(sdk): preserve generated event contracts 2026-05-31 03:18:43 -04:00
opencode-agent[bot] 1afa9e32c9 chore: update nix node_modules hashes 2026-05-31 01:21:53 +00:00
opencode-agent[bot] 102c8353e0 chore: generate 2026-05-31 01:09:55 +00:00
Dax 7f571d36ea refactor(core): move database schema ownership (#29068)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 21:08:38 -04:00
opencode-agent[bot] 6bcb9cb9bb chore: generate 2026-05-31 00:04:25 +00:00
Dax Raad 0269d6f5de fix(core): isolate location layer instances 2026-05-30 20:02:14 -04:00
Dax Raad aa86a20b1d fix(core): provide agent service in location layer 2026-05-30 20:01:54 -04:00
opencode 74ce1a1edf sync release versions for v1.15.13 2026-05-30 23:40:56 +00:00
opencode-agent[bot] 9c7788bbf9 chore: generate 2026-05-30 23:31:35 +00:00
Jérôme Benoit f4f508e659 fix(opencode): support sap-ai-core anthropic opus 4.7+ adaptive reasoning (#29991) 2026-05-30 18:30:21 -05:00
opencode-agent[bot] 30f9780561 chore: update nix node_modules hashes 2026-05-30 23:13:09 +00:00
Aiden Cline 3070b0f4ae fix(opencode): default display summarized for gateway opus 4.7+ adaptive reasoning (#30027) 2026-05-30 18:07:06 -05:00
opencode-agent[bot] 52e288ea71 chore: generate 2026-05-30 22:59:16 +00:00
Dax Raad 9b915e70bd v2: default agents 2026-05-30 18:57:42 -04:00
Shantur Rathore ddc30cd151 feat(core): add session metadata support (#23068)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-30 16:58:11 -05:00
Frank ac8e686f33 zen: batch balance calculation 2026-05-30 10:53:35 -04:00
Ariane Emory bd3a6d76e1 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-30 07:25:26 -04:00
opencode-agent[bot] 69b2784b13 chore: generate 2026-05-30 08:56:10 +00:00
vimtor 6a2cd816b7 core: credit referral invites on first Lite checkout
Applies saved invites during Lite checkout so users who skip the referral page still credit the inviter. Avoids crediting already-active Lite workspaces so rewards stay tied to the first upgrade.
2026-05-30 10:52:21 +02:00
opencode-agent[bot] 04c46117b9 chore: generate 2026-05-30 05:13:21 +00:00
Frank 51a9b0baad zen: log ip prefix 2026-05-30 01:11:20 -04:00
opencode-agent[bot] bcc345ff82 chore: update nix node_modules hashes 2026-05-30 05:09:31 +00:00
opencode-agent[bot] 0d2508ffd5 chore: generate 2026-05-30 04:51:39 +00:00
Dax Raad f6062bd5b6 feat(core): add default agent plugin 2026-05-30 00:49:52 -04:00
Dax Raad 181e58f50a feat(cli): add effect cli scaffold 2026-05-30 00:49:52 -04:00
opencode-agent[bot] 1ceb5dbd56 chore: update nix node_modules hashes 2026-05-30 04:19:30 +00:00
opencode-agent[bot] e4d3b81b0e chore: generate 2026-05-30 04:07:26 +00:00
Dax 9583e08be4 feat(core): add location-scoped config loading (#29625) 2026-05-30 04:06:08 +00:00
Kit Langton 5fb85a6aa3 fix(tui): align wrapped inline tool rows (#28664) 2026-05-29 21:50:05 -04:00
opencode-agent[bot] 124714ca3a fix(tui): keep command palette available in questions 2026-05-29 23:19:01 +00:00
Vogel 4ebd1272f2 test: update serve help snapshot 2026-05-29 18:26:54 -04:00
Ryan Vogel 765b8debe3 Merge branch 'dev' into server-discovery 2026-05-29 18:16:11 -04:00
Vogel b54cffc3e1 feat: show the active connected server in the TUI 2026-05-29 18:07:02 -04:00
opencode-agent[bot] b2a06351b5 chore: generate 2026-05-29 21:02:33 +00:00
James Murdza 9d5f3c1833 fix(workspaces): surface real error messages on failed workspace operations (#29167)
Signed-off-by: James Murdza <james@jamesmurdza.com>
2026-05-29 17:00:23 -04:00
Adam 7342e94094 fix(stats): scroll dense mobile charts 2026-05-29 15:11:21 -05:00
opencode-agent[bot] 8fe6cd9932 chore: generate 2026-05-29 20:06:03 +00:00
Shoubhit Dash 4cc166a400 feat(acp): promote next implementation (#29929) 2026-05-30 01:34:44 +05:30
Aiden Cline 0733c080c0 ignore: bug report template
Updated bug report template to discourage large AI-generated summaries.
2026-05-29 14:54:45 -05:00
opencode-agent[bot] 73c7bef1d0 chore: generate 2026-05-29 19:22:38 +00:00
Adam 9854c5d910 fix(stats): stabilize stacked chart bands 2026-05-29 14:20:23 -05:00
opencode-agent[bot] 494b55a2a7 chore: generate 2026-05-29 19:02:42 +00:00
Adam aa22cf841d fix(stats): reorder market share section 2026-05-29 14:00:08 -05:00
Adam c977cb7f31 Combine top models and leaderboard sections 2026-05-29 14:00:08 -05:00
James Long a4bc76d2a4 chore: remove improve codebase architecture skill (#29926) 2026-05-29 14:57:55 -04:00
opencode-agent[bot] 39cd14826a chore: generate 2026-05-29 18:34:40 +00:00
Adam 6b0e5499f5 fix(stats): tighten 2m chart gaps 2026-05-29 13:31:25 -05:00
opencode-agent[bot] a87b27a8be chore: generate 2026-05-29 18:24:08 +00:00
Adam ce4e0e8a4e fix(stats): rotate dense chart labels 2026-05-29 13:22:25 -05:00
Aiden Cline b956e9a06f fix(opencode): support vertex opus adaptive reasoning (#29911) 2026-05-29 11:35:26 -05:00
Adam 16cae9a323 fix(stats): stack largest segments at bottom 2026-05-29 09:30:32 -05:00
Adam 0a60dbdcee fix(stats): stabilize newsletter form reset 2026-05-29 09:03:16 -05:00
Adam bccebb8a36 fix(stats): refine newsletter modal 2026-05-29 08:08:57 -05:00
Adam 76dbe10ad7 feat(stats): match theme icons to figma (#29893) 2026-05-29 08:04:34 -05:00
Adam 46836beec9 fix(stats): add social unfurl metadata (#29892) 2026-05-29 08:02:00 -05:00
Adam e84988b2a3 fix(stats): ignore partial may 27 athena data (#29891) 2026-05-29 12:59:47 +00:00
vimtor 3f6cb631c3 fix: referral edge case 2026-05-29 14:50:16 +02:00
Adam e9966b651f feat(stats): improve mobile leaderboard layout 2026-05-29 07:46:10 -05:00
Adam dc28b82394 Add live GitHub star count to stats page 2026-05-29 07:46:09 -05:00
Adam fb6275e04a fix(stats): improve mobile charts 2026-05-29 07:46:09 -05:00
Jack 6102fb2e3f docs: update Go availability and Zen pricing (#29890) 2026-05-29 20:39:23 +08:00
Shoubhit Dash 7da2620078 fix(tui): pin dialog select footer to bottom (#29878) 2026-05-29 16:43:50 +05:30
vimtor 5764f19937 core: credit users for missed referral rewards 2026-05-29 13:04:53 +02:00
opencode-agent[bot] 41198186be chore: generate 2026-05-29 10:11:21 +00:00
Shoubhit Dash 8f8b161cae feat(tui): add session switcher plugin (#29861) 2026-05-29 15:39:34 +05:30
opencode-agent[bot] 710ed7cb33 chore: generate 2026-05-29 09:21:36 +00:00
Shoubhit Dash fa73ec4fa6 fix(opencode): serialize mcp auth mutations (#29852) 2026-05-29 14:50:15 +05:30
Aiden Cline c7e1fc5e42 fix(opencode): retry stalled SSE streams (#29837) 2026-05-29 00:19:54 -05:00
Aiden Cline a15d4f9f04 fix(openai): proxy websocket connections under bun (#29832) 2026-05-28 23:53:07 -05:00
opencode-agent[bot] 43f110a6cb chore: generate 2026-05-29 03:49:46 +00:00
Luke Parker 29d17b9055 fix(opencode): allow pid 0 in Pty.Info for Windows ConPTY (#29828) 2026-05-29 13:47:50 +10:00
opencode-agent[bot] 031f82adc8 chore: update nix node_modules hashes 2026-05-29 00:51:14 +00:00
Luke Parker 672b8c4762 fix(desktop): bump @lydell/node-pty to 1.2.0-beta.12 (#29803) 2026-05-29 10:35:20 +10:00
Adam 0dc44772df fix(stats): tune dark tooltip colors 2026-05-28 19:14:41 -05:00
Saurav M H 797c689ec2 fix(opencode): pass OAuth scopes to GoogleAuth for Vertex AI (#15110)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-28 19:05:43 -05:00
opencode-agent[bot] acca8864ba chore: generate 2026-05-28 23:51:26 +00:00
Adam 7480b97630 fix(stats): proxy build assets under stats path 2026-05-28 18:49:03 -05:00
opencode-agent[bot] edc7a20cea chore: update nix node_modules hashes 2026-05-28 23:38:53 +00:00
Adam 58da1ea48e feat(stats): route stats app 2026-05-28 18:37:13 -05:00
Adam 0d67d659fc feat(stats): polish rankings page 2026-05-28 18:22:56 -05:00
opencode-agent[bot] 6a9087007c chore: generate 2026-05-28 22:44:48 +00:00
Adam c63d532cc2 refactor: simplify the changed workflow 2026-05-28 17:43:15 -05:00
Adam 629d5593eb feat(stats): split updated timestamp ticker 2026-05-28 17:43:14 -05:00
Adam ffbf9df18e Refine leaderboard layout and styling 2026-05-28 17:43:14 -05:00
Adam e3a6255c72 Fix touch interaction in top models chart 2026-05-28 17:43:14 -05:00
Frank 9f15715877 zen: sync 2026-05-28 18:41:17 -04:00
opencode-agent[bot] ecd645b023 chore: update nix node_modules hashes 2026-05-28 22:08:51 +00:00
NoisemakerJon 4d894d129e fix(app): prevent horizontal jitter on /go page (#29796) 2026-05-28 16:56:24 -05:00
opencode-agent[bot] 4759ae44b4 chore: generate 2026-05-28 21:54:17 +00:00
Vladimir Glafirov e00a62e461 chore: bump gitlab-ai-provider to 6.8.0 (#29792) 2026-05-28 16:52:26 -05:00
Luke Parker e16bfd745d fix(app): start MCP servers only for open directories (#28937) 2026-05-28 21:51:11 +00:00
Aiden Cline 7b56a1cea3 fix(tui): order connected provider models by release date (#29798) 2026-05-28 16:37:32 -05:00
Guilherme França 1f707f1b52 fix(dialog-provider): change url for opencode-go provider to correct… (#29780) 2026-05-28 15:49:24 -05:00
opencode-agent[bot] caf2451dae chore: generate 2026-05-28 20:01:06 +00:00
William Ricchiuti de7bb076a3 docs: add opencode-goal-plugin to ecosystem (#29788) 2026-05-28 14:59:24 -05:00
Vadim Safonov d924685939 docs: fix russian translation (#29742) 2026-05-28 14:59:05 -05:00
opencode 569230bfbc sync release versions for v1.15.12 2026-05-28 19:34:46 +00:00
Aiden Cline cbc4b062f7 fix(openai): preserve websocket upgrade error messages (#29774) 2026-05-28 14:34:28 -05:00
opencode-agent[bot] 824ecf2f4d chore: generate 2026-05-28 19:33:59 +00:00
Adam b623d86f10 Improve top models chart mobile axis 2026-05-28 14:32:09 -05:00
Adam 93ba2dd24a Remove hero borders from stats page 2026-05-28 14:32:08 -05:00
Adam f55b70b4e3 feat(stats): refine top models chart scaling 2026-05-28 14:32:08 -05:00
Adam 982b7d39e7 Adjust stats header layout 2026-05-28 14:32:08 -05:00
opencode-agent[bot] 2d15d89140 chore: update nix node_modules hashes 2026-05-28 18:05:19 +00:00
Frank 2eb0606998 zen: add opus 4.8 2026-05-28 13:48:05 -04:00
opencode-agent[bot] cc230feca1 chore: generate 2026-05-28 17:45:17 +00:00
Adam 3ce9b4be0d fix(stats): refine hero layout for responsive Figma match 2026-05-28 12:43:16 -05:00
Adam fff7781fa8 fix(stats): preload IBM Plex Mono weights 2026-05-28 12:43:15 -05:00
Adam 0a72298062 feat(stats): better hero 2026-05-28 12:43:15 -05:00
Adam 7c320fd463 feat(stats): better header 2026-05-28 12:43:15 -05:00
Aiden Cline 05e3c4ecee fix(opencode): enable adaptive reasoning for opus 4.7+ (#29769) 2026-05-28 12:19:34 -05:00
Filip 72d008bd5c fix(core): allow colons inside passwords (#29576)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-05-28 13:17:35 -04:00
Avnish Uba 202ec12056 docs: fix grammar in config.mdx (#29683)
Co-authored-by: dnsnav <dnsnav@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 11:25:34 -05:00
Aiden Cline af30b315b2 fix(tui): restore thinking spinner (#29765) 2026-05-28 11:24:45 -05:00
opencode-agent[bot] 425a4f584f chore: generate 2026-05-28 13:39:37 +00:00
Adam 7504daa602 fix(stats): batch honeycomb backfill 2026-05-28 08:37:49 -05:00
Adam 9ac0f3e9ac fix(stats): better stat table periods, provider/model norm 2026-05-28 08:37:48 -05:00
Adam cf2cd13fb8 chore: updated backfill 2026-05-28 08:37:48 -05:00
opencode-agent[bot] 6883309997 chore: generate 2026-05-28 13:24:10 +00:00
Shoubhit Dash c3562a4a51 test(acp): remove timing diagnostics (#29741) 2026-05-28 18:52:37 +05:30
opencode-agent[bot] 30a66dfc31 chore: generate 2026-05-28 11:51:48 +00:00
Victor Navarro fdc574ff81 feat: improve referral system (#29720) 2026-05-28 13:50:14 +02:00
opencode-agent[bot] 1e5ddbd812 chore: generate 2026-05-28 10:56:37 +00:00
Shoubhit Dash 3109060216 fix(acp): cover smoke parity gaps (#29719) 2026-05-28 16:25:00 +05:30
Shoubhit Dash 9031ce7b51 fix(acp): speed up acp-next warm switches (#29713) 2026-05-28 15:10:17 +05:30
Shoubhit Dash 2449b50585 fix(acp): improve acp-next first-session startup (#29709) 2026-05-28 14:53:47 +05:30
opencode-agent[bot] 66e6662440 chore: generate 2026-05-28 08:48:09 +00:00
Shoubhit Dash 56f3b9044d test(acp-next): add local verifier subprocess tests (#29700) 2026-05-28 14:16:49 +05:30
Luke Parker 913659890d fix(openai): keep websocket response timeouts active (#29699) 2026-05-28 18:28:01 +10:00
Aiden Cline 14e0b9b17f fix(openai): retry websocket stream failures (#29673) 2026-05-28 01:16:18 -05:00
opencode-agent[bot] e5524f5bf9 chore: generate 2026-05-28 05:15:21 +00:00
Shoubhit Dash 16234a0baf feat(acp): implement acp-next prompt (#29664) 2026-05-28 10:43:59 +05:30
Frank e5cf3ad3c2 zen: fix db issue 2026-05-28 00:50:15 -04:00
opencode-agent[bot] 82da3320ba chore: generate 2026-05-28 03:43:10 +00:00
Shoubhit Dash aa553dea94 fix(acp): handle acp-next permission events (#29656) 2026-05-28 09:11:49 +05:30
opencode-agent[bot] 0c9cad86ec chore: generate 2026-05-28 02:59:53 +00:00
James Long 69910f361c fix(server): use persisted session directory for existing-session routes (#29640) 2026-05-27 22:58:33 -04:00
Kit Langton 7bafbb309a fix(opencode): forward remote workspace request bodies (#29458) 2026-05-27 21:54:36 -04:00
DS ec26d78450 fix(openai): support websocket custom base URLs (#29636) 2026-05-27 18:46:30 -05:00
opencode-agent[bot] 49e8d324dd chore: update nix node_modules hashes 2026-05-27 22:16:44 +00:00
Sebastian 60b9ba4db7 upgrade opentui to 0.2.16 (#29463) 2026-05-28 00:01:32 +02:00
opencode-agent[bot] a1a401dc9b chore: update nix node_modules hashes 2026-05-27 20:22:29 +00:00
opencode-agent[bot] 2f1547ebaf chore: generate 2026-05-27 20:05:11 +00:00
Aiden Cline 62da1e7682 feat(openai): add responses websocket transport (#29477) 2026-05-27 15:02:37 -05:00
James Long 28a06e52fc feat(tui): add workspace management dialog (#29612) 2026-05-27 18:09:40 +00:00
Jack 5a5d981c4e docs(zen): add MiMo-V2.5 Free model (#29610) 2026-05-28 01:34:37 +08:00
opencode-agent[bot] 62a7781e04 chore: generate 2026-05-27 16:31:18 +00:00
Jack 2d03cf4a9a docs(go): update MiMo (#29598) 2026-05-28 00:29:31 +08:00
opencode-agent[bot] 4025d30b24 chore: generate 2026-05-27 15:53:52 +00:00
Adam dd3c443d8c fix: cpu bound ingest server 2026-05-27 10:51:36 -05:00
Adam 4aaa9f9e30 chore: better stats command 2026-05-27 10:50:34 -05:00
Adam 31c805bc97 chore: honeycomb backfill script 2026-05-27 10:37:02 -05:00
Sebastian 94f2ed1b84 keep session navigation active in prompt modes (#29464) 2026-05-27 17:26:56 +02:00
gotjoshua 86dc66eae9 docs: fix ts error in math examples (#18244) 2026-05-27 10:12:12 -05:00
Aiden Cline 9814dc6526 fix(tui): surface subagent retry status (#29591) 2026-05-27 10:11:01 -05:00
Adam f09c859974 fix: memory pressure in ingest 2026-05-27 09:22:08 -05:00
Adam 1fcdb0246a fix: bun install 2026-05-27 08:37:34 -05:00
opencode-agent[bot] 340ad9e9ed chore: generate 2026-05-27 12:47:27 +00:00
Kit Langton 76d814e747 refactor(server): unify instance httpapi middleware routing
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
2026-05-27 08:45:11 -04:00
Adam 9941e70d29 fix: better dockerfile 2026-05-27 06:55:44 -05:00
Adam 2afb812620 fix: stats dockerfile 2026-05-27 06:39:39 -05:00
Adam ad2fc1ce4b chore: reestablish datalake 2026-05-27 06:17:16 -05:00
Victor Navarro 5acc368ef4 perf: use redis for api key rate limit (#29242) 2026-05-27 11:27:06 +02:00
Brendan Allan e2dc89c6f3 fix(ui): migrate to --v2-font-family-sans variable and improve home empty state (#29552) 2026-05-27 09:18:52 +00:00
Brendan Allan e1581183ff feat(app): allow toggling tabs layout (#29526) 2026-05-27 15:42:21 +08:00
Brendan Allan f195c952fc fix(app): show tab close button properly (#29523) 2026-05-27 06:36:04 +00:00
opencode-agent[bot] 34b1045a01 chore: generate 2026-05-27 04:58:46 +00:00
OpeOginni aa9956d46d fix(tui): handle non-git project paths when opening editor (#29180) 2026-05-26 23:57:24 -05:00
opencode cec5a752bb sync release versions for v1.15.11 2026-05-27 04:01:11 +00:00
opencode-agent[bot] 0f0a4be253 fix(project): restore remote project identity (#29495) 2026-05-27 03:09:20 +00:00
opencode-agent[bot] 0e88dd99e7 chore: generate 2026-05-27 02:24:30 +00:00
Aiden Cline f965db9e13 feat: add headerTimeout cfg option, default it on only for openai w/ default of 10s (#29484) 2026-05-26 21:22:24 -05:00
Aiden Cline 519d344470 feat(plugin): add dispose hook (#29493) 2026-05-26 21:21:53 -05:00
Aiden Cline a78605f8ea fix(plugin): use codex session-id header (#29471) 2026-05-26 19:16:07 -05:00
Aiden Cline c0bc020ad6 fix(opencode): disconnect dynamically added mcp servers (#29452) 2026-05-26 18:28:03 -05:00
Frank 0e669bbd18 zen: disable models 2026-05-26 18:49:10 -04:00
Dax Raad 00a6f228f2 fix(project): preserve cached project identity 2026-05-26 17:50:42 -04:00
Kit Langton 1462f9caf3 test: cover TUI optimistic prompt sync 2026-05-26 17:16:05 -04:00
Kit Langton ebaad55c8c tui: simplify optimistic prompt reconciliation 2026-05-26 17:14:34 -04:00
Kit Langton f5c770d65e tui: optimistically render submitted prompts 2026-05-26 17:13:38 -04:00
James Long 0ba1081cf1 fix(tui): accelerate diff viewer scrolling (#29453) 2026-05-26 20:53:13 +00:00
Musa 0448a30821 fix(digitalocean): use OAuth token directly for inference instead of creating MAK (#28897)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-26 14:20:25 -05:00
Sebastian 848d763d08 Prepare TUI lifecycle for scenario tests (#28258) 2026-05-26 21:02:02 +02:00
Dustin Deus fdfd0afed7 docs: add tip to lsp section (#29444)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-05-26 13:59:05 -05:00
Dustin Deus 5f31d2ca88 docs(web): update LSP docs wording (#29435)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-05-26 12:59:51 -05:00
Frank afdae3950a sync 2026-05-26 13:41:49 -04:00
opencode-agent[bot] 50b869a508 chore: update nix node_modules hashes 2026-05-26 17:40:59 +00:00
opencode-agent[bot] cf8b03ec10 chore: generate 2026-05-26 17:29:38 +00:00
Jack 1554a5a82e docs(go): add Qwen3.7 Max (#29389) 2026-05-26 13:26:57 -04:00
Aiden Cline 797359fbf0 fix(opencode): patch gcp metadata warning leak (#29431) 2026-05-26 12:18:09 -05:00
opencode-agent[bot] 034298cb1f chore: update nix node_modules hashes 2026-05-26 16:55:51 +00:00
Aiden Cline 1c4196c358 docs: remove ambiguous agent automation guidance (#29427) 2026-05-26 11:46:45 -05:00
Aiden Cline 28dbd4ab4a chore(opencode): pin google providers before tool id regression (#29423) 2026-05-26 11:34:54 -05:00
vimtor 715589fea4 fix: stuck sst deployment 2026-05-26 18:07:50 +02:00
opencode-agent[bot] 922903e410 chore: generate 2026-05-26 15:52:47 +00:00
vimtor 541a25c2a1 fix: stuck sst deployment 2026-05-26 17:48:27 +02:00
opencode-agent[bot] 1bf2b298e9 chore: update nix node_modules hashes 2026-05-26 14:23:57 +00:00
Frank 5ee18f9b75 ci: fix 2026-05-26 10:18:37 -04:00
Frank 26b1d14c16 ci: fix 2026-05-26 10:11:41 -04:00
opencode-agent[bot] 249dc0e01e chore: generate 2026-05-26 14:03:28 +00:00
Frank 8ad03547df zen: support dashboard 2026-05-26 10:01:20 -04:00
Eric Guo 1a8fd0e1dc fix(app): handle deleted session sync cache in V2 titlebar (#29328) 2026-05-26 13:23:17 +00:00
Adam 46140b0cc8 chore: better sync/ingest logging 2026-05-26 06:35:49 -05:00
opencode-agent[bot] ca354f8e8a chore: generate 2026-05-26 07:51:29 +00:00
Luke Parker 13c9d097a0 feat(app): refine desktop v2 home and session controls (#28788) 2026-05-26 07:50:07 +00:00
Luke Parker 1f66db0e4e test(app): restore timeline e2e server health mock (#29336) 2026-05-26 07:36:46 +00:00
Brendan Allan c71fe78970 fix(app): disable health check for web deployments (#29319) 2026-05-26 15:26:59 +08:00
Shoubhit Dash 717e74f3e5 feat(acp): stream acp-next tool updates (#29333) 2026-05-26 12:48:31 +05:30
Shoubhit Dash b5632ea700 fix(config): fallback when user info unavailable (#29332) 2026-05-26 12:42:12 +05:30
opencode-agent[bot] 8845a43e9e chore: generate 2026-05-26 06:48:48 +00:00
Shoubhit Dash 7e5305c765 feat(acp-next): add event routing (#29327) 2026-05-26 12:17:20 +05:30
opencode-agent[bot] 245f00a4b3 chore: generate 2026-05-26 06:17:48 +00:00
Shoubhit Dash 15c5ec6d24 feat(acp): add acp-next session lifecycle (#29320) 2026-05-26 11:45:24 +05:30
Brendan Allan 49707c2117 fix(app): deduplicate and merge server connections in resolveServerList (#29313) 2026-05-26 13:41:13 +08:00
opencode-agent[bot] dbb7872326 chore: update nix node_modules hashes 2026-05-26 05:01:16 +00:00
Aiden Cline 87e9e700cd fix(opencode): revert google sdk tool call id change (#29310) 2026-05-25 23:42:33 -05:00
Adam 4862c3e765 chore: remove gh role from infra 2026-05-25 20:35:45 -05:00
Brendan Allan b0fcba5724 feat(app): make server sdk + sync state global (#29285) 2026-05-26 08:20:57 +08:00
Adam 2b3ddf9f34 chore: cleanup 2026-05-25 18:18:34 -05:00
Adam 3c7f608475 fix: aws stages 2026-05-25 18:14:45 -05:00
opencode-agent[bot] 1ed14151a5 chore: update nix node_modules hashes 2026-05-25 22:49:12 +00:00
opencode-agent[bot] 16a30041b3 chore: generate 2026-05-25 22:35:28 +00:00
Adam 5b02ac4d33 feat: initial datalake and stats site (#28666) 2026-05-25 17:34:04 -05:00
Aiden Cline 633b5d6208 fix: allow experimental flags to override umbrella (#29273) 2026-05-25 16:42:34 -05:00
opencode-agent[bot] d595e472b3 chore: generate 2026-05-25 21:30:39 +00:00
Robert Poschenrieder 0bfa55b623 tweak (config): make modalities input/output fields optional so that u can specify one without both being required (#29268)
Co-authored-by: Robert Poschenrieder <Robert_Poschenrieder@epdeberw000f.fritz.box>
2026-05-25 16:29:11 -05:00
opencode-agent[bot] d96e3a5423 chore: generate 2026-05-25 21:11:17 +00:00
Aiden Cline dabf2dc013 remove the need for polling from experimental background agents (#29179) 2026-05-25 16:09:56 -05:00
Kit Langton 775321173d refactor(httpapi): describe bodyless global upgrade payload 2026-05-25 16:18:31 -04:00
Kit Langton b46cec2a7d fix(httpapi): model optional worktree payload as no content (#29246) 2026-05-25 15:57:12 -04:00
Kit Langton a99ebd1d65 fix(httpapi): model optional session payloads as no content (#29247) 2026-05-25 15:56:52 -04:00
Shoubhit Dash a5ea910eac fix(acp-next): add config switch fast paths (#29255) 2026-05-26 00:24:03 +05:30
Shoubhit Dash 56743dcf04 fix(acp): share acp-next session state (#29253) 2026-05-25 23:09:41 +05:30
opencode-agent[bot] 00ea47a502 chore: generate 2026-05-25 17:26:51 +00:00
Shoubhit Dash 0373ea9128 feat(acp): implement acp-next session slice (#29250) 2026-05-25 22:55:30 +05:30
Jack e1406e05a3 fix(console): bill google non-stream zen usage (#28829) 2026-05-25 13:07:50 -04:00
opencode-agent[bot] b5553839d0 chore: generate 2026-05-25 16:49:25 +00:00
Shoubhit Dash 249381e742 feat(acp-next): add usage service (#29249) 2026-05-25 22:17:11 +05:30
opencode-agent[bot] 7499021132 chore: generate 2026-05-25 16:27:24 +00:00
Shoubhit Dash 9dd24d7d03 feat(acp-next): add session state service (#29240) 2026-05-25 21:56:05 +05:30
Shoubhit Dash b2d76434ba feat(acp-next): add directory snapshot service (#29241) 2026-05-25 21:55:52 +05:30
opencode-agent[bot] d18eab5b85 chore: generate 2026-05-25 15:26:53 +00:00
Shoubhit Dash 7a5a997173 fix(acp-next): map typed errors to request errors (#29233) 2026-05-25 20:55:39 +05:30
Shoubhit Dash d200da121b feat(acp-next): add pure tool conversion helpers (#29232) 2026-05-25 20:55:22 +05:30
Shoubhit Dash fe482fe3dd test(acp-next): add config option helpers (#29234) 2026-05-25 20:54:57 +05:30
Shoubhit Dash 2fce3c1370 feat(acp-next): add content conversion helpers (#29231) 2026-05-25 20:53:58 +05:30
Shoubhit Dash 7060cfa59b feat(acp): add initial acp-next skeleton behind runtime flag (#29226) 2026-05-25 20:33:40 +05:30
opencode-agent[bot] 8077e8a9d1 chore: update nix node_modules hashes 2026-05-25 15:03:04 +00:00
opencode-agent[bot] d37166397c chore: generate 2026-05-25 14:50:18 +00:00
Shoubhit Dash 756c7c60bd test(acp): add compatibility baseline (#29222) 2026-05-25 20:19:06 +05:30
opencode-agent[bot] 94fb361591 chore: generate 2026-05-25 14:41:16 +00:00
Victor Navarro fdff82e6fc perf: use redis/upstash for ip rate limits (#28694) 2026-05-25 16:39:56 +02:00
opencode-agent[bot] b14e7451ab chore: generate 2026-05-25 14:25:37 +00:00
Braxton Schafer 0de5f1ff36 feat(tui): make prompt size responsive and configurable (#28255) 2026-05-25 09:24:19 -05:00
Victor Navarro 7703786498 perf: serve console from us-east-2 instead of us-east-1 (#28640) 2026-05-25 11:42:18 +02:00
André Cruz 748fcb7ebd fix(session): exclude orphaned interrupted tools from run-loop continuation (#26178)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-25 00:47:55 -05:00
LukeParkerDev 930526ddcd Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/app/src/app.tsx
#	packages/app/src/index.ts
#	packages/desktop/src/main/constants.ts
#	packages/desktop/src/main/index.ts
2026-05-23 10:59:21 +10:00
LukeParkerDev 5334fd6b81 fix(desktop): avoid autostarting WSL servers 2026-05-19 08:16:06 +10:00
LukeParkerDev 4cf3eb04cd Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/desktop/src/main/apps.ts
#	packages/desktop/src/main/index.ts
#	packages/desktop/src/main/ipc.ts
#	packages/desktop/src/main/server.ts
2026-05-19 08:00:22 +10:00
Ariane Emory 571ae48e14 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-18 06:15:05 -04:00
Dax Raad 9a0a1a801e test: avoid global fetch patching for well-known auth 2026-05-17 22:17:13 -04:00
Ariane Emory d34eca2f0d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-17 19:41:19 -04:00
Dax Raad 5b1085d229 fix: store well-known logins in new auth service 2026-05-17 16:37:21 -04:00
Dax Raad 1f053c4942 test: share empty well-known auth layer 2026-05-17 16:32:20 -04:00
Dax Raad c25128a6f5 feat: add well-known auth service 2026-05-17 16:27:50 -04:00
Ariane Emory 24277b51e3 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-16 14:46:59 -04:00
Dax Raad 26f64f29fb refactor(cli): scope server discovery to CLI 2026-05-15 20:28:53 -04:00
Dax Raad 13822b9424 feat(cli): discover running serve instances 2026-05-15 20:25:59 -04:00
Ariane Emory 9332560ffd Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-11 15:49:47 -04:00
Ryan Vogel 5aad254f68 feat(tui): expand pasted summaries on click 2026-05-09 23:11:26 +00:00
LukeParkerDev 0ba2bea40c Merge remote-tracking branch 'upstream/dev' into pr-23407-merge
# Conflicts:
#	packages/desktop/src/index.tsx
#	packages/desktop/src/main/index.ts
#	packages/desktop/src/main/wsl-servers.ts
#	packages/desktop/src/main/wsl.ts
2026-05-06 10:21:50 +10:00
LukeParkerDev e16115f53f fix(desktop): improve WSL OpenCode updates 2026-05-05 15:27:10 +10:00
LukeParkerDev 26bf234228 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-05 15:15:18 +10:00
LukeParkerDev 148b3cf034 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-05 13:18:52 +10:00
LukeParkerDev de69c5af0a fix(app): stabilize server switching 2026-05-04 23:34:03 +10:00
LukeParkerDev e5a4f6f603 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-04 23:25:59 +10:00
Ariane Emory 4e8020b503 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-04 06:26:02 -04:00
LukeParkerDev 5778008596 deslop 2026-05-04 20:18:28 +10:00
LukeParkerDev b70fc22a4b fix(desktop): remove stale WSL toggle plumbing 2026-05-04 20:02:58 +10:00
LukeParkerDev affbea691b Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/opencode/src/cli/cmd/github.ts
#	packages/opencode/src/cli/cmd/providers.ts
2026-05-04 13:23:05 +10:00
LukeParkerDev cecaf82445 deslop 2026-05-04 13:20:21 +10:00
LukeParkerDev 777169ac17 deslop 2026-05-04 11:28:57 +10:00
LukeParkerDev 58f569fbf7 deslop 2026-05-04 11:26:25 +10:00
LukeParkerDev 7dd8b2b7f9 deslop 2026-05-04 11:24:14 +10:00
LukeParkerDev fcb4bc1b3a deslop 2026-05-04 11:20:57 +10:00
LukeParkerDev 6ada6e0db9 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/desktop-electron/src/renderer/webview-zoom.ts
2026-05-03 12:51:32 +10:00
LukeParkerDev cc1d7e5d5b Update dialog-wsl-server.tsx 2026-05-03 10:27:03 +10:00
LukeParkerDev 013bd4c918 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-03 10:16:35 +10:00
LukeParkerDev 4799efb9bf Delete todo.md 2026-04-29 08:38:08 +10:00
LukeParkerDev 2eb3072e3e review 2026-04-29 08:37:16 +10:00
LukeParkerDev 9cd611780f clean 2026-04-28 12:20:01 +10:00
LukeParkerDev 70443cd2de kill 2026-04-28 09:20:16 +10:00
LukeParkerDev ca8e6e4d67 clean 2026-04-28 09:15:25 +10:00
LukeParkerDev 7bcb519e0c wip 2026-04-28 09:03:48 +10:00
LukeParkerDev 4f6caacde4 . 2026-04-28 08:13:09 +10:00
LukeParkerDev 81e907febe . 2026-04-28 08:02:30 +10:00
LukeParkerDev ed4a41f1e0 . 2026-04-28 07:57:25 +10:00
LukeParkerDev cea9a9905d Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-27 16:15:37 +10:00
Ariane Emory 09e4e5a184 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-23 21:55:13 -04:00
LukeParkerDev 2e0b993d5d wip 2026-04-20 12:54:12 +10:00
LukeParkerDev babaf781da server key 2026-04-19 21:09:06 +10:00
LukeParkerDev a17ce350f1 tinkering 2026-04-19 20:59:38 +10:00
LukeParkerDev 0e04141849 tinkering 2026-04-19 20:35:43 +10:00
LukeParkerDev 29130af9ec Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-19 20:35:34 +10:00
LukeParkerDev 9235753aa0 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-19 19:44:07 +10:00
LukeParkerDev 796991b3ea oops 2026-04-19 19:37:28 +10:00
LukeParkerDev 6ae05c541d fix(desktop): remove deprecated wsl platform properties from tauri 2026-04-19 13:23:24 +10:00
LukeParkerDev bc84698428 feedback n stuff? 2026-04-19 13:21:48 +10:00
LukeParkerDev e3d2a9ddbb clean? 2026-04-19 13:20:34 +10:00
LukeParkerDev 3d26bbed82 clean maybe 2026-04-19 13:20:34 +10:00
LukeParkerDev 3ad63536bf chore: remove solid-js owner/cleanup instrumentation
The instrumentation diagnosed the cleanNode reentry bug that's since been fixed (Terminal queueMicrotask, SortableWorkspace/LocalWorkspace undefined guards). Keeping it around imposes significant main-thread cost on busy subtrees: wraps every owner with accessor/Proxy traps, captures a fresh Error().stack on every owned mutation, and pushes to a ring buffer. On a ~40k-owner graph after a server switch this saturated the main thread and tripped Electron's unresponsive watchdog a few minutes in. Drop the file and its import.
2026-04-19 13:20:33 +10:00
LukeParkerDev f347d9a58a fix(desktop-wsl): drop DefaultUid=0 first-run gate, --user root bypasses OOBE
Verified with live probes: 'wsl -d Ubuntu-24.04 --user root -- echo OK' succeeds on a distro whose registry DefaultUid is still 0 (OOBE never ran). The prior hang symptom was from invoking wsl WITHOUT --user when DefaultUid=0, which triggers the interactive 'Create a default UNIX user account' prompt. With --user root explicit in wslArgs the OOBE hook is skipped and resolveWslOpencode finds /root/.opencode/bin/opencode etc. Remove the overly aggressive first-run gate from spawnWslSidecar; the registry probe helpers stay in wsl.ts for future diagnostic use. 20s timeout + failure logging remain as safety nets for true wsl.exe wedges.
2026-04-19 13:20:32 +10:00
LukeParkerDev 3822c0aaec fix(desktop-wsl): use reg.exe for LXSS registry probe instead of powershell
The PowerShell HKCU Registry-provider enumeration returned nothing when spawned by Electron's main process (both Ubuntu-24.04 and Debian were reported as 'not installed'), so the first-run gate blocked every distro unconditionally. reg.exe is a native Windows binary with a stable line-oriented output format that works regardless of PS host quirks; parse its 'HKEY_CURRENT_USER\\\\...\\\\Lxss\\\\{guid}' subkey blocks, pull DistributionName + DefaultUid + State + Version, and rebuild the list.
2026-04-19 13:20:09 +10:00
LukeParkerDev 16ada93dd4 fix(desktop-wsl): skip sidecar spawn when distro first-run is incomplete
Reading HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss tells us the DefaultUid for every registered distro without touching wsl.exe. On a freshly installed Ubuntu-24.04 the 'Create a default UNIX user account' prompt never ran and DefaultUid stays 0; every wsl.exe -d <distro> ... invocation in that state silently blocks on stdin forever, even with --user root. checkWslDistroFirstRun reads the registry via powershell, and spawnWslSidecar throws a human-readable error if the distro still needs setup, so the controller marks the server as failed with a clear message instead of hanging. Also emits 'wsl sidecar starting' to main.log for visibility.
2026-04-19 13:20:09 +10:00
LukeParkerDev 6fc5f342dd fix(desktop-wsl): time-bound every wsl.exe invocation to fail fast on wedge
Ubuntu-24.04 in the failed first-run state wedges wsl.exe silently - no stdout, no exit. Without a timeout resolveWslOpencode (and any other runWsl call) blocks the sidecar spawn flow forever, which hides the real failure from logs and from the controller's failed runtime state. Add a 20s default ceiling to runCommand; caller can override for long-running jobs (installs).
2026-04-19 13:19:41 +10:00
LukeParkerDev 0e7e791008 fix(layout): guard LocalWorkspace + projectId prop against undefined project
Previous commit only guarded SortableWorkspace. LocalWorkspace (rendered from layout.tsx:2243 with the same project={project()!} pattern) still accessed props.project.worktree directly inside four createMemo / useQuery bodies and threw the same 'worktree of undefined' during a server swap cascade. Route every worktree read through a worktree memo that returns the string or '', and guard the projectId prop on WorkspaceHeader.
2026-04-19 13:19:40 +10:00
LukeParkerDev 11528e43c0 chore(desktop-wsl): log sidecar start failures and flatten server-health logs
The WSL controller previously only stored a failed startup message in its renderer state, so Ubuntu-style silent failures left no trace in main.log. Inject the main-process logger into the controller and emit wsl sidecar ready / wsl sidecar failed to start entries. Also flatten the [server health] renderer logs into a single string argument because Electron's console-message bridge truncates extra args to [object Object].
2026-04-19 13:19:15 +10:00
LukeParkerDev 4560435dd9 fix(desktop-wsl): spawn WSL commands as root to bypass first-run setup
A freshly installed Ubuntu-24.04 distro prompts interactively for a new UNIX user on its first invocation; with piped stdio that prompt blocks forever and the sidecar never starts. Adding --user root to wslArgs sidesteps the whole first-run flow for every wsl.exe we spawn (sidecar, resolveWslOpencode, probes). opencode inside WSL only needs an HTTP listener so running as root is fine.
2026-04-19 13:19:15 +10:00
LukeParkerDev 3349fb95ca fix(layout): guard SortableWorkspace against undefined project during server swap
bootstrapDirectory's setStore fires while a server swap is cascading; currentProject() can already be undefined by the time these memos re-run, which threw 'Cannot read properties of undefined (reading worktree)' from local/workspaceValue/WorkspaceActions root. Optional-chain the props.project reads so the component survives the transient undefined before the enclosing Show unmounts it.
2026-04-19 13:19:14 +10:00
LukeParkerDev bff9e576b7 feat(desktop): show splash overlay during server switch
ServerKey's keyed <Show> remount is a multi-second synchronous cascade (dispose + rebuild of the whole app subtree) that used to leave the UI looking frozen. A tiny module-level serverSwitching signal now gates a fullscreen Splash rendered above the ServerKey boundary, and the status-popover click handler setTimeout-defers the batched navigate+setActive so the browser paints the splash before the freeze begins and dismisses it after the new subtree paints.
2026-04-19 13:19:14 +10:00
LukeParkerDev da2e640029 chore: improve desktop renderer diagnostics
Serialize non-Error promise rejections so unhandled rejections print type/ctor/keys/JSON instead of the unreadable '[object Object]'. Also emit [server health] logs when a health poll returns unhealthy and when polling switches servers, so a red dot in the status popover comes with a logged URL and auth presence. Minor cosmetic: restore session-header StatusPopover import position after the earlier titlebar experiment.
2026-04-19 13:19:13 +10:00
LukeParkerDev 8fd7bd19d6 fix: defer terminal cleanup state write to stop cleanNode reentry crash
Terminal onCleanup ran persistTerminal synchronously during a dispose cascade, which flowed through props.onCleanup -> ops.update -> update() in context/terminal.tsx and fired setStore on the terminal store. That store write reentered the reactive graph mid cleanNode iteration; solid then nulled an ancestors owned while an outer cleanNode recursion was still iterating it, crashing with Cannot read properties of null reading 1 at node.owned[i]. Wrapping finalize in queueMicrotask pushes the store write past the current synchronous cleanup cascade so the teardown cannot race with cleanNodes owned walk.
2026-04-19 13:19:13 +10:00
LukeParkerDev 902ac2dad9 chore: add solid owner/cleanup instrumentation for diagnosing cleanNode crashes
Hooks DEV.hooks.afterCreateOwner to wrap every owner's .owned and .cleanups with accessor traps that record every mutation to a ring buffer with tags, stacks, and cleanup-depth context. On any 'Cannot read properties of null' TypeError the buffer is dumped so the offending cleanup/origin that nulled an owner's owned mid-iteration is visible post-hoc. Also wraps owned arrays in a Proxy so cleanNode's index reads are logged and the suspect ownerTag at crash time can be identified. Debug only; zero cost until a crash fires.
2026-04-19 13:19:12 +10:00
LukeParkerDev 33f5b80235 fix: defer reactive root disposal in cache cleanups
Same nested-dispose-in-onCleanup bug as 7f36ac2481 but in three more
places: TerminalProvider.disposeAll, PromptProvider.disposeAll, and
scoped-cache.clear() (covers viewCache.clear and comments cache.clear).
All of them synchronously call createRoot dispose() on cached entries
inside onCleanup, which during a server switch nests into the outer
cleanNode cascade and throws TypeError at chunk-*.js:992.

Snapshot the pending disposers, clear the cache synchronously, and
fire the disposers on a microtask so the outer cleanup finishes first.
2026-04-19 13:19:12 +10:00
LukeParkerDev d04d13ea22 fix: defer child-store root disposal to avoid nested cleanNode
disposeDirectory called a createRoot dispose() synchronously. When
triggered by pinForOwner's onCleanup during a parent remount (e.g.
switching to a WSL server re-keys the ServerKey Show), the inner
dispose ran a nested cleanNode cascade on a sibling root while the
outer cascade was mid-traversal, corrupting solid-js's graph walk
state and surfacing as TypeError: Cannot read properties of null
(reading '1') at chunk-*.js:992 after ~155 recursive cleanNode frames.

Queue the dispose on a microtask so synchronous bookkeeping still
runs (map deletes, onDispose cache invalidation) but the reactive
cleanup happens after the outer traversal finishes.
2026-04-19 13:19:11 +10:00
LukeParkerDev cfcc6f1353 fix: batch server switch to avoid cleanNode crash
The status popover and select-server dialog used to call navigate('/') then
defer server.setActive to the next microtask. With multiple sidecars in v2,
that split triggered two separate disposal cascades - one for the route
change and a second for the ServerKey Show re-key - and the sidebar project
bucket also swaps (local -> wsl:Debian), tearing down every solid-dnd
sortable in the middle. Wrapping both calls in batch() lands them in a
single Solid update so disposal runs once.

Also raise Error.stackTraceLimit to 200 so future disposal crashes capture
the originating frame instead of truncating at the tenth cleanNode.
2026-04-19 13:19:11 +10:00
LukeParkerDev 3360480a2a fix: bind F12 and Ctrl+Shift+I to DevTools on all platforms 2026-04-19 13:19:10 +10:00
LukeParkerDev 3e7e709884 fix: preserve full error stacks in desktop renderer logs
Electron's console-message event only surfaces {level, message, line, sourceId}
without the stack, so uncaught errors showed up as 'line 1028 of chunk-*.js'
(SolidJS's rethrow site) with no way to find the real origin. Attach
window error and unhandledrejection listeners that log the full stack via
console.error, and reshape the main-process log line so newlines in the
stack survive instead of being JSON-escaped into one unreadable blob.
2026-04-19 13:19:09 +10:00
LukeParkerDev c510661ef3 fix: stop random hotkeys from snapping desktop zoom to 1
The main process was resetting webContents zoom to 1 on every
\zoom-changed\ event, which fires not just for native Chromium zoom
gestures but also for the renderer's own setZoomFactor IPC calls. Paired
with a keydown listener that re-sent the current zoom on every
ctrl-combination (ctrl+backspace, ctrl+z, ctrl+v, ...), this created a
self-triggered race that intermittently snapped the factor back to 1.

Make the renderer the single source of zoom truth: keyboard, wheel, and
menu all drive the same Solid signal, preventDefault blocks Chromium's
built-in accelerators before they race, and the main process disables
pinch zoom and no longer listens to zoom-changed.
2026-04-19 13:19:09 +10:00
LukeParkerDev 12fa782137 refactor: treat WSL servers as additive alongside windows local
Local Server is always Windows-native now; WSL lives as a separate list
of one-or-more distro-bound sidecars spawned alongside it. Manage Servers
shows an Add WSL button on Windows, each WSL server appears as its own
row with remove + retry, and the wizard runs scoped to a new distro.
2026-04-19 13:19:09 +10:00
LukeParkerDev 66dfdb933d fix: pin desktop renderer dev server to loopback 2026-04-19 13:19:08 +10:00
LukeParkerDev 3b1970a0f4 docs: reframe WSL work as additive servers beside windows local 2026-04-19 13:19:07 +10:00
LukeParkerDev d8b1d86092 Update dialog-local-server.tsx 2026-04-19 13:19:07 +10:00
LukeParkerDev d7111a7072 fix: prefer nerd fonts in desktop terminal 2026-04-19 13:19:06 +10:00
LukeParkerDev 17bd7ffbf1 refactor: confirm windows local server swap inline 2026-04-19 13:19:06 +10:00
LukeParkerDev 9fa3a99480 fix: make WSL opencode mismatch non-blocking 2026-04-19 13:19:06 +10:00
LukeParkerDev 4318b0902d fix: keep session header titlebar mounts in sync 2026-04-19 13:19:05 +10:00
LukeParkerDev 06b27db78e fix: keep sidebar projects stable across WSL refreshes 2026-04-19 13:18:36 +10:00
LukeParkerDev fcc9cab760 fix: separate sidebar drag ids by type 2026-04-19 13:18:36 +10:00
LukeParkerDev 0dae445f4f fix: detect opencode in existing WSL distros 2026-04-19 13:15:59 +10:00
LukeParkerDev 15092be204 fix: catch desktop local startup errors 2026-04-19 13:15:58 +10:00
LukeParkerDev 482ef946fe fix: remove route dependency from server dialog 2026-04-19 13:15:58 +10:00
LukeParkerDev 421f3bbd0b fix: surface desktop WSL startup failures 2026-04-19 13:15:57 +10:00
LukeParkerDev 76da54c596 fix: harden local server WSL onboarding checks 2026-04-19 13:15:26 +10:00
LukeParkerDev 1baa87bf0d fix: tighten local server wizard dialog flow 2026-04-19 13:15:26 +10:00
LukeParkerDev 8554345ba0 feat: turn local server setup into guided step flow 2026-04-19 13:15:25 +10:00
LukeParkerDev b321a2de2b fix: treat WSL sidecars as local 2026-04-19 13:15:25 +10:00
LukeParkerDev 2cd61113c1 feat: show other distro install options 2026-04-19 13:15:24 +10:00
LukeParkerDev c1dc769b5f fix: send plain local server config over ipc 2026-04-19 13:15:24 +10:00
LukeParkerDev 08d422dca1 fix: include local server stack traces in renderer logs 2026-04-19 13:15:24 +10:00
LukeParkerDev 6f2a6356ed fix: surface desktop renderer errors in logs 2026-04-19 13:15:23 +10:00
LukeParkerDev 58ab95e32c feat: add restart-to-apply local runtime changes 2026-04-19 13:15:23 +10:00
LukeParkerDev 5aa544179d feat: branch Electron local startup by runtime 2026-04-19 13:15:22 +10:00
LukeParkerDev 482dc3a15d refactor: carry local runtime metadata through startup 2026-04-19 13:14:44 +10:00
LukeParkerDev 710469cf09 feat: add local server opencode setup flow 2026-04-19 13:14:43 +10:00
LukeParkerDev e897883f91 feat: deep-link local server failures into management 2026-04-19 13:14:43 +10:00
LukeParkerDev 8d8e8fe8f4 feat: add local server management dialog shell 2026-04-19 13:14:42 +10:00
LukeParkerDev df635562e9 refactor: expose local server through app platform 2026-04-19 13:14:42 +10:00
LukeParkerDev c575415ec4 feat: add local server install actions 2026-04-19 13:14:41 +10:00
LukeParkerDev c0e449d3ac feat: add local server transcript and terminal action 2026-04-19 13:14:41 +10:00
LukeParkerDev ec1a5c261e feat: add Electron local server probe actions 2026-04-19 13:14:40 +10:00
LukeParkerDev 4212931808 feat: add Electron WSL command helpers 2026-04-19 13:14:40 +10:00
LukeParkerDev 1a215700c8 refactor: derive Electron WSL paths from local server config 2026-04-19 13:14:39 +10:00
LukeParkerDev 9d4737fdc9 refactor: track Electron local server startup state 2026-04-19 13:14:39 +10:00
LukeParkerDev d6f2b9f1b7 refactor: give desktop local server a windows key 2026-04-19 13:14:39 +10:00
LukeParkerDev 8af7b5cd65 refactor: scaffold Electron local server config 2026-04-19 13:14:38 +10:00
LukeParkerDev ba145fd61a docs: add WSL onboarding implementation backlog 2026-04-19 13:13:53 +10:00
LukeParkerDev 02a8711f48 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 13:10:30 +10:00
LukeParkerDev 41a6067a04 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 12:41:03 +10:00
LukeParkerDev 0f92cec469 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 12:10:22 +10:00
LukeParkerDev 5acfe3a20c Merge remote-tracking branch 'upstream/dev' into dev 2026-04-17 16:24:57 +10:00
Ariane Emory 731c1e58f2 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-16 20:22:02 -04:00
LukeParkerDev 4ef581c111 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-16 17:17:43 +10:00
LukeParkerDev a888bf2194 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-16 11:00:00 +10:00
LukeParkerDev f8a23b428d Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 17:15:00 +10:00
Brendan Allan c92aaf8b80 fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) 2026-04-15 07:03:00 +00:00
LukeParkerDev 79552d31e6 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 14:48:37 +10:00
LukeParkerDev 885bb25f13 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 08:38:55 +10:00
LukeParkerDev 987afa3d71 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-14 09:56:39 +10:00
LukeParkerDev a3f5c5d14d Merge remote-tracking branch 'upstream/dev' into dev 2026-04-13 16:24:08 +10:00
Ariane Emory c411d37484 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-12 04:22:06 -04:00
Luke Parker da2e91cf60 ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 10:01:10 +10:00
Luke Parker f327584e0a fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 10:00:46 +10:00
Ariane Emory 97a94571a4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-03 09:19:12 -04:00
Ariane Emory 6652585a7f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 11:17:40 -04:00
Ariane Emory 532b64c0d5 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 07:43:03 -04:00
Ariane Emory eec4c775a7 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-23 21:10:21 -04:00
Ariane Emory 01e350449c Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 19:12:18 -04:00
Dax 5792a80a8c Merge branch 'dev' into feat/auto-accept-permissions 2026-03-20 10:46:31 -04:00
Dax Raad db039db7f5 regen js sdk 2026-03-20 10:21:10 -04:00
Dax Raad c1a3936b61 Merge remote-tracking branch 'origin/dev' into feat/auto-accept-permissions
# Conflicts:
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-03-20 10:20:26 -04:00
Ariane Emory a9d9e4d9c4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 03:35:16 -04:00
Ariane Emory 2531b2d3a9 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-13 11:47:39 -04:00
Ariane Emory a718f86e0f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 19:28:41 -04:00
Ariane Emory f3efdff861 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 08:36:02 -04:00
Ariane Emory 955d8591df Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-05 18:24:19 -05:00
Ariane Emory 33b3388bf4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 17:50:11 -05:00
Ariane Emory 716f40b128 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 01:36:39 -05:00
Ariane Emory 0b06ff1407 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-20 21:24:12 -05:00
Ariane Emory 01ff5b5390 Merge branch 'dev' into feat/canceled-prompts-in-history
# Conflicts:
#	packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
2026-02-20 02:16:02 -05:00
Ariane Emory 3d1b121e70 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-19 19:18:48 -05:00
Ariane Emory b70629af27 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-18 19:10:26 -05:00
Ariane Emory b7b016fa28 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-17 00:09:51 -05:00
Ariane Emory 5ba2d7e5f0 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-15 12:27:51 -05:00
Ariane Emory 459b22b83d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-14 19:21:47 -05:00
Ariane Emory 377812b98a Merge dev into feat/canceled-prompts-in-history 2026-02-14 06:28:48 -05:00
Ariane Emory 5cc0901e38 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-13 09:37:11 -05:00
Ariane Emory 7fb6b589d1 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-12 18:29:23 -05:00
Ariane Emory 3f37b43e7d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-11 12:46:47 -05:00
Ariane Emory 8805dfc849 fix: deduplicate prompt history entries
Avoid adding duplicate entries to prompt history when the same input
is appended multiple times (e.g., clearing with ctrl+c then restoring
via history navigation and clearing again).
2026-02-10 22:21:39 -05:00
Ariane Emory ac5a5d8b16 Merge branch 'feat/canceled-prompts-in-history' of github.com:ariane-emory/opencode into feat/canceled-prompts-in-history 2026-02-10 16:37:55 -05:00
Ariane Emory eaf94ed047 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:29:05 -05:00
Ariane Emory b8031c5ae8 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:10:35 -05:00
Dax Raad a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad 405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad 878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Ariane Emory d5dcadc000 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-07 13:34:42 -05:00
Ariane Emory 0c154e6a2f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-06 15:59:50 -05:00
Ariane Emory 4f96975148 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-05 18:17:01 -05:00
Ariane Emory eaba99711b Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-04 19:33:59 -05:00
Ariane Emory f762125775 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-03 18:36:44 -05:00
Ariane Emory ded6bb6513 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-02 21:23:28 -05:00
Ariane Emory 39332f5be6 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-01 22:33:29 -05:00
Ariane Emory 2c6ff35400 feat: add toggle to control whether cleared prompts are saved to history
Adds a toggle command in the System category that allows users to enable
or disable saving cleared prompts to history. The feature is disabled by
default to preserve existing behavior.

When enabled via the command palette ("Include cleared prompts in history"),
pressing Ctrl+C will save the current prompt to history before clearing it,
allowing users to navigate back with arrow keys.

The setting persists in kv.json.
2026-02-01 21:12:48 -05:00
Ariane Emory 738d6c8899 feat: save prompt to history when cleared with Ctrl+C
When users press Ctrl+C to clear the input field, the current prompt
is now saved to history before clearing. This allows users to navigate
back to cleared prompts using arrow keys, preventing loss of work.

Addresses #11489
2026-02-01 21:01:15 -05:00
993 changed files with 81330 additions and 26308 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.opencode
.sst
.turbo
.wrangler
node_modules
**/node_modules
**/.output
**/dist
**/.turbo
**/.vite
**/coverage
+2
View File
@@ -0,0 +1,2 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report
description: Report an issue that should be fixed
description: Report an issue that should be fixed (avoid pasting giant AI generated summaries or your issue may be closed/ignored)
body:
- type: textarea
id: description
+12
View File
@@ -9,9 +9,15 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
id-token: write
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -21,6 +27,12 @@ jobs:
with:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
aws-region: us-east-1
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3
View File
@@ -2,6 +2,9 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
},
"mcp": {},
"tools": {
"github-triage": false,
@@ -1,37 +0,0 @@
# Deepening
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
## Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
### 1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
### 2. Local-substitutable
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
### 3. Remote but owned (Ports & Adapters)
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_
### 4. True external (Mock)
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
## Seam discipline
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
## Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
- Write new tests at the deepened module's interface. The **interface is the test surface**.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
@@ -1,44 +0,0 @@
# Interface Design
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
## Process
### 1. Frame the problem space
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
- The constraints any new interface would need to satisfy
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
### 2. Spawn sub-agents
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
- Agent 1: "Minimize the interface — aim for 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
@@ -1,53 +0,0 @@
# Language
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
## Terms
**Module**
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
_Avoid_: unit, component, service.
**Interface**
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
**Implementation**
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
**Depth**
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
**Seam** _(from Michael Feathers)_
A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
_Avoid_: boundary (overloaded with DDD's bounded context).
**Adapter**
A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside).
**Leverage**
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
**Locality**
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
## Principles
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape.
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
## Relationships
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
- **Depth** is a property of a **Module**, measured against its **Interface**.
- A **Seam** is where a **Module**'s **Interface** lives.
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
## Rejected framings
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
@@ -1,71 +0,0 @@
---
name: improve-codebase-architecture
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
---
# Improve Codebase Architecture
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
## Glossary
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
- **Module** — anything with an interface and an implementation (function, class, package, slice).
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
- **Implementation** — the code inside.
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
- **Adapter** — a concrete thing satisfying an interface at a seam.
- **Leverage** — what callers get from depth.
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
- **The interface is the test surface.**
- **One adapter = hypothetical seam. Two adapters = real seam.**
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
## Process
### 1. Explore
Read the project's domain glossary and any ADRs in the area you're touching first.
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
- Where does understanding one concept require bouncing between many small modules?
- Where are modules **shallow** — interface nearly as complex as the implementation?
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
- Where do tightly-coupled modules leak across their seams?
- Which parts of the codebase are untested, or hard to test through their current interface?
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
### 2. Present candidates
Present a numbered list of deepening opportunities. For each candidate:
- **Files** — which files/modules are involved
- **Problem** — why the current architecture is causing friction
- **Solution** — plain English description of what would change
- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
### 3. Grilling loop
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
Side effects happen inline as decisions crystallize:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
+6 -2
View File
@@ -1,8 +1,6 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles
@@ -49,6 +47,12 @@ obj.b
const { a, b } = obj
```
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
+256 -64
View File
@@ -23,13 +23,13 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "4.13.1",
"sst": "catalog:",
"turbo": "2.8.13",
},
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -83,9 +83,26 @@
"vite-plugin-solid": "catalog:",
},
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.15.13",
"bin": {
"opencode": "./src/index.ts",
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -102,6 +119,7 @@
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"@upstash/redis": "1.38.0",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
@@ -120,7 +138,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +165,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -169,7 +187,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -191,9 +209,29 @@
"cloudflare": "5.2.0",
},
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.15.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vite": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"wrangler": "4.50.0",
},
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
"version": "1.15.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -206,8 +244,8 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -221,8 +259,11 @@
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -230,11 +271,13 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"ai-gateway-provider": "3.1.2",
"cross-spawn": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"gitlab-ai-provider": "6.7.0",
"gitlab-ai-provider": "6.8.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
@@ -247,14 +290,16 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"drizzle-kit": "catalog:",
},
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -291,12 +336,12 @@
"zod-openapi": "5.4.6",
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -309,7 +354,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -321,9 +366,21 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.15.10",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -353,7 +410,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -369,7 +426,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -382,7 +439,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -400,7 +457,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.10",
"version": "1.15.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -416,8 +473,8 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -457,6 +514,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -470,7 +528,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.7.0",
"gitlab-ai-provider": "6.8.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -498,6 +556,7 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -528,7 +587,6 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
@@ -538,7 +596,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -554,9 +612,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
"@opentui/core": ">=0.3.1",
"@opentui/keymap": ">=0.3.1",
"@opentui/solid": ">=0.3.1",
},
"optionalPeers": [
"@opentui/core",
@@ -576,7 +634,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -591,7 +649,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -602,6 +660,68 @@
"typescript": "catalog:",
},
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.15.13",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"d3-scale": "4.0.2",
"effect": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"sst": "catalog:",
"vite": "catalog:",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/bun": "catalog:",
"@types/d3-scale": "4.0.9",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.15.13",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"sst": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
},
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.15.13",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"effect": "catalog:",
"sst": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"devDependencies": {
@@ -626,7 +746,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -675,7 +795,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.10",
"version": "1.15.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -718,8 +838,9 @@
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
@@ -738,13 +859,13 @@
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
"@lydell/node-pty": "1.2.0-beta.12",
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.3.1",
"@opentui/keymap": "0.3.1",
"@opentui/solid": "0.3.1",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -782,6 +903,7 @@
"shiki": "3.20.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
@@ -833,9 +955,9 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.128", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.77", "@ai-sdk/google": "3.0.73", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -907,8 +1029,12 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9eMUCu1Ay3C9ojo+dJcynSdpbxuwDVtZUt/Xhce+c2+mgDsmvRzjww+wfLpZwRNWxBWmeauQQAZk52tCwQgXsQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/credential-provider-node": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7Ne3Yk/bgQPVebAkv7W+RfhiwTRSbfER9BtbhOa2w/+dIr902LrJf6vrZlxiqaJbGj2ALx8M+ZK1YIHVxSwu9A=="],
"@aws-sdk/client-firehose": ["@aws-sdk/client-firehose@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-tDrtgczN2lQsflLDPYu/wdOoyCZLVYtgzmWnYzSEOBWd/cp2AbuQ7D+FemSwUTzyoMTuhhIevyEJKzqsF+QYxA=="],
"@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ryEYNVdilyWkKsOs/7Xy/l7+qjtSz4sll8NpcWD6AtONxjG/5OMaAhxxDkQb4iBoNMKnISxsARzQAp/Wa8pXIg=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.933.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-bucket-endpoint": "3.930.0", "@aws-sdk/middleware-expect-continue": "3.930.0", "@aws-sdk/middleware-flexible-checksums": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-location-constraint": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/middleware-ssec": "3.930.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/signature-v4-multi-region": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-KxwZvdxdCeWK6o8mpnb+kk7Kgb8V+8AjTwSXUWH1UAD85B0tjdo1cSfE5zoR5fWGol4Ml5RLez12a6LPhsoTqA=="],
@@ -1417,19 +1543,19 @@
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="],
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="],
"@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="],
"@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ=="],
"@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="],
"@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q=="],
"@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="],
"@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw=="],
"@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.10", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="],
"@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.12", "", { "os": "linux", "cpu": "x64" }, "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ=="],
"@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="],
"@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g=="],
"@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.10", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="],
"@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.12", "", { "os": "win32", "cpu": "x64" }, "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
@@ -1555,6 +1681,8 @@
"@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"],
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1565,12 +1693,16 @@
"@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"],
"@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"],
"@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"],
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1587,6 +1719,12 @@
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
"@opencode-ai/stats-core": ["@opencode-ai/stats-core@workspace:packages/stats/core"],
"@opencode-ai/stats-server": ["@opencode-ai/stats-server@workspace:packages/stats/server"],
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
@@ -1621,23 +1759,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core": ["@opentui/core@0.3.1", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.1", "@opentui/core-darwin-x64": "0.3.1", "@opentui/core-linux-arm64": "0.3.1", "@opentui/core-linux-x64": "0.3.1", "@opentui/core-win32-arm64": "0.3.1", "@opentui/core-win32-x64": "0.3.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-kQFSsSCgtlasSqTigCgKmM67xaquGvTg+vwimDnFSZtcBEt4E3dz7qLrbeh5FVvTA+RMbwe+Bozq03PW+SgjXw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krvVfiBpeBY+727R8yogdqIcxkK3RUVcI97bqjl8jTeDMcWOkFFfHezssRMPmbR5x++1tX669Fz3fuxoe7XUIg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-D/6ec5H8SPpSBMr01/sqgSddIl1Qc1QMKsDl/wV5MpbxYc7Qvie9qlNvvoSsWNfAXAbafLRb1jQBzouk41cp1w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/FFBoAsWJyS/EO/cF7h7DuEENYa9nAdSv1W/TIyKXpBisN6K3U1Xgbk528TkfWjrwJjhGs+9OMYdXuAHd5LTw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Btb7Q4BOC55Aj2qCs0VoxGuj87DNfUEaSx0z89oeU4npTN+6SpJApyGZTCNNeSe2sdmOGeh/8eAR4X96ORjcKg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+lt24u3KwEPG69oXDOLz9N484wPcAHvrPbDNU77OT6DvWew+StAjh40eY+Zeu0TkTNDWfj7qnQKV0GKWtFA3cw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-eVkKMYirYgpn92lI0YT/GKru4J+UiXjzwyzNRFX+P59OHXvL3GFdqJMcJmX4/zvyjg4c8HDnU79YLnyG+TlXLw=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/keymap": ["@opentui/keymap@0.3.1", "", { "dependencies": { "@opentui/core": "0.3.1" }, "peerDependencies": { "@opentui/react": "0.3.1", "@opentui/solid": "0.3.1", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BTj+ggsarO2uyvd6CWzvgfsekA8c4aEclbAPKPZGVjBI3Fo5+KAHUrXvteFO5qpGMANfEJTtVHoRu5cic1Nlaw=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@opentui/solid": ["@opentui/solid@0.3.1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.1", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2R6wEijfMub9COTBCm8IKVj2y7+Sc4fZZjJawxk8sE6+++mzeUaokKNJTlYhZXpMju4LKMv6j9CjWkG8JYfbcg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2325,6 +2463,10 @@
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -2469,6 +2611,8 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
@@ -2895,6 +3039,20 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -3301,7 +3459,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.7.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-J7apROAmDwDJcLPcbQ9YQiPhyFN1Vvt/f3ugsePaUnzh3sedfHCHGETgG0Mm0dJCB2rC7DYwmV/sgxLQcQ/sjw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -3469,6 +3627,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
@@ -4835,7 +4995,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -5021,7 +5181,7 @@
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
@@ -5117,7 +5277,7 @@
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
@@ -5177,6 +5337,16 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/client-athena/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="],
"@aws-sdk/client-athena/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="],
"@aws-sdk/client-athena/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
"@aws-sdk/client-athena/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
"@aws-sdk/client-athena/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
@@ -5201,6 +5371,16 @@
"@aws-sdk/client-cognito-identity/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-firehose/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="],
"@aws-sdk/client-firehose/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="],
"@aws-sdk/client-firehose/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
"@aws-sdk/client-firehose/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
"@aws-sdk/client-firehose/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-lambda/@aws-sdk/core": ["@aws-sdk/core@3.974.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.42", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", "@aws-sdk/credential-provider-http": "^3.972.39", "@aws-sdk/credential-provider-ini": "^3.972.41", "@aws-sdk/credential-provider-process": "^3.972.37", "@aws-sdk/credential-provider-sso": "^3.972.41", "@aws-sdk/credential-provider-web-identity": "^3.972.41", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ=="],
@@ -5359,14 +5539,14 @@
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node/undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="],
"@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -5501,6 +5681,8 @@
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/core/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -5511,6 +5693,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/script/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5821,6 +6005,8 @@
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -5835,6 +6021,8 @@
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"nitro/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -5853,6 +6041,8 @@
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -5937,6 +6127,8 @@
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@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"]
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"]
[test]
root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: {
worker: (args) => {
args.logpush = true
if ($app.stage === "vimtor") return
if ($app.stage === "vimtor" || $app.stage === "adam") return
args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings,
{
+7 -3
View File
@@ -1,7 +1,9 @@
import { domain } from "./stage"
import { deployAws, domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
const lake = deployAws ? await import("./lake") : undefined
////////////////
// DATABASE
////////////////
@@ -240,7 +242,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])],
})
new sst.cloudflare.x.SolidStart("Console", {
@@ -250,6 +252,8 @@ new sst.cloudflare.x.SolidStart("Console", {
bucket,
bucketNew,
database,
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL,
@@ -281,7 +285,7 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-1" },
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
+322
View File
@@ -0,0 +1,322 @@
import { domain } from "./stage"
const current = aws.getCallerIdentityOutput({})
const partition = aws.getPartitionOutput({})
const region = aws.getRegionOutput({})
const tableBucketName = `opencode-${$app.stage}-lake`
const glueCatalogName = "s3tablescatalog"
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
name: tableBucketName,
forceDestroy: $app.stage !== "production",
})
const s3TablesCatalog = new aws.cloudcontrol.Resource(
"LakeS3TablesCatalog",
{
typeName: "AWS::Glue::Catalog",
desiredState: $jsonStringify({
Name: glueCatalogName,
Description: "Federated catalog for S3 Tables",
FederatedCatalog: {
Identifier: s3TablesBucketWildcardArn,
ConnectionName: "aws:s3tables",
},
CreateDatabaseDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
CreateTableDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
AllowFullTableExternalDataAccess: "True",
}),
},
{ dependsOn: [tableBucket] },
)
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
bucket: `opencode-${$app.stage}-lake-athena-results`,
forceDestroy: $app.stage !== "production",
})
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
forceDestroy: $app.stage !== "production",
})
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
name: `opencode-${$app.stage}-lake-workgroup`,
forceDestroy: $app.stage !== "production",
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
},
})
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRole"],
principals: [
{
type: "Service",
identifiers: ["firehose.amazonaws.com"],
},
],
},
],
}).json,
})
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
role: firehoseRole.id,
policy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: [
"s3tables:ListTableBuckets",
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
"s3tables:PutTableData",
"s3tables:UpdateTableMetadataLocation",
],
resources: ["*"],
},
{
effect: "Allow",
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:UpdateTable",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
effect: "Allow",
actions: [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
],
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
},
{
effect: "Allow",
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
],
}).json,
})
const firehose = new aws.kinesis.FirehoseDeliveryStream(
"LakeFirehose",
{
name: `opencode-${$app.stage}-lake-ingest`,
destination: "iceberg",
icebergConfiguration: {
appendOnly: true,
bufferingInterval: 60,
bufferingSize: 1,
catalogArn: glueS3TablesChildCatalogArn,
processingConfiguration: {
enabled: true,
processors: [
{
type: "MetadataExtraction",
parameters: [
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
{
parameterName: "MetadataExtractionQuery",
parameterValue:
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
},
],
},
],
},
roleArn: firehoseRole.arn,
s3BackupMode: "FailedDataOnly",
s3Configuration: {
roleArn: firehoseRole.arn,
bucketArn: firehoseErrorBucket.arn,
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
},
},
},
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
)
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
export const lakeRegion = region.region
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
export const lakeAthenaWorkgroup = athenaWorkgroup
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
properties: {
streamName: firehose.name,
secret: ingestSecret.result,
},
})
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "1 vCPU",
memory: "4 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
link: [ingestConfig],
permissions: [
{
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
resources: [firehose.arn],
},
],
scaling: {
min: $app.stage === "production" ? 2 : 1,
max: $app.stage === "production" ? 32 : 4,
cpuUtilization: 60,
memoryUtilization: 70,
},
loadBalancer: {
domain: {
name: `lake.${domain}`,
dns: sst.cloudflare.dns(),
},
rules: [
{ listen: "80/http", redirect: "443/https" },
{ listen: "443/https", forward: "3000/http" },
],
health: {
"3000/http": {
path: "/ready",
successCodes: "200-299",
},
},
},
health: {
command: [
"CMD-SHELL",
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
],
interval: "30 seconds",
retries: 3,
startPeriod: "30 seconds",
timeout: "5 seconds",
},
dev: {
command: "bun run start",
directory: "packages/stats/server",
url: "http://localhost:3000",
},
wait: $app.stage === "production",
})
export const lakeIngest = new sst.Linkable("LakeIngest", {
properties: {
url: ingestService.url,
secret: ingestSecret.result,
},
})
export const lakeQueryPermissions = [
{
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
resources: [athenaWorkgroup.arn],
},
{
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:GetPartitions",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
resources: [athenaResultsBucket.arn],
},
{
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
},
{
actions: [
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
],
resources: ["*"],
},
{
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
]
+3
View File
@@ -7,5 +7,8 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
+2
View File
@@ -5,6 +5,8 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+208
View File
@@ -0,0 +1,208 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
import { EMAILOCTOPUS_API_KEY } from "./app"
const domain = (() => {
if ($app.stage === "production") return "stats.opencode.ai"
if ($app.stage === "dev") return "stats.dev.opencode.ai"
return `stats.${$app.stage}.dev.opencode.ai`
})()
////////////////
// LAKE
////////////////
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
namespace: "inference",
tableBucketArn: tableBucket.arn,
})
const inferenceEventTable = new aws.s3tables.Table(
"LakeInferenceEventTable",
{
name: "event",
namespace: inferenceNamespace.namespace,
tableBucketArn: inferenceNamespace.tableBucketArn,
format: "ICEBERG",
metadata: {
iceberg: {
schema: {
fields: [
{ name: "event_timestamp", type: "string", required: false },
{ name: "event_date", type: "string", required: false },
{ name: "event_type", type: "string", required: false },
{ name: "dataset", type: "string", required: false },
{ name: "cf_continent", type: "string", required: false },
{ name: "cf_country", type: "string", required: false },
{ name: "cf_city", type: "string", required: false },
{ name: "cf_region", type: "string", required: false },
{ name: "cf_latitude", type: "double", required: false },
{ name: "cf_longitude", type: "double", required: false },
{ name: "cf_timezone", type: "string", required: false },
{ name: "duration", type: "double", required: false },
{ name: "request_length", type: "long", required: false },
{ name: "status", type: "int", required: false },
{ name: "ip", type: "string", required: false },
{ name: "is_stream", type: "boolean", required: false },
{ name: "session", type: "string", required: false },
{ name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false },
{ name: "error_type", type: "string", required: false },
{ name: "error_message", type: "string", required: false },
{ name: "error_cause", type: "string", required: false },
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
{ name: "time_to_first_byte", type: "long", required: false },
{ name: "timestamp_first_byte", type: "long", required: false },
{ name: "timestamp_last_byte", type: "long", required: false },
{ name: "tokens_input", type: "long", required: false },
{ name: "tokens_output", type: "long", required: false },
{ name: "tokens_reasoning", type: "long", required: false },
{ name: "tokens_cache_read", type: "long", required: false },
{ name: "tokens_cache_write_5m", type: "long", required: false },
{ name: "tokens_cache_write_1h", type: "long", required: false },
{ name: "cost_input_microcents", type: "long", required: false },
{ name: "cost_output_microcents", type: "long", required: false },
{ name: "cost_cache_read_microcents", type: "long", required: false },
{ name: "cost_cache_write_microcents", type: "long", required: false },
{ name: "cost_total_microcents", type: "long", required: false },
{ name: "cost_input", type: "long", required: false },
{ name: "cost_output", type: "long", required: false },
{ name: "cost_cache_read", type: "long", required: false },
{ name: "cost_cache_write_5m", type: "long", required: false },
{ name: "cost_cache_write_1h", type: "long", required: false },
{ name: "cost_total", type: "long", required: false },
],
},
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
properties: {
region: lakeRegion,
catalog: lakeCatalog,
database: inferenceNamespace.namespace,
table: inferenceEventTable.name,
tableBucket: tableBucket.name,
workgroup: lakeAthenaWorkgroup.name,
},
})
////////////////
// DATABASE
////////////////
const cluster = planetscale.getDatabaseOutput({
name: "opencode-stats",
organization: "anomalyco",
})
const branch =
$app.stage === "production"
? planetscale.getBranchOutput({
name: "production",
organization: cluster.organization,
database: cluster.name,
})
: new planetscale.Branch("StatsDatabaseBranch", {
database: cluster.name,
organization: cluster.organization,
name: $app.stage,
parentBranch: "production",
})
const password = new planetscale.Password("StatsDatabasePassword", {
name: $app.stage,
database: cluster.name,
organization: cluster.organization,
branch: branch.name,
})
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
encodeURIComponent,
)}@${password.accessHostUrl}/${cluster.name}`
export const database = new sst.Linkable("StatsDatabase", {
properties: {
host: password.accessHostUrl,
database: cluster.name,
username: password.username,
password: password.plaintext,
port: 3306,
url: databaseUrl,
},
})
new sst.x.DevCommand("StatsStudio", {
link: [database],
environment: {
DATABASE_URL: databaseUrl,
},
dev: {
command: "bun db:studio",
directory: "packages/stats/core",
autostart: false,
},
})
////////////////
// APP
////////////////
export const app = new sst.cloudflare.x.SolidStart("Stats", {
path: "packages/stats/app",
buildCommand: "bun run build",
domain,
link: [database, EMAILOCTOPUS_API_KEY],
environment: {
PUBLIC_URL: `https://${domain}/stats`,
},
})
////////////////
// SERVICES
////////////////
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
properties: {
dataset: "zen",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
max: 1,
},
dev: {
command: "bun src/stat-sync.ts",
directory: "packages/stats/server",
autostart: false,
},
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Nol27sqG6za6oR32MqZrmRxch0/XFeQ61C9Yqtxo7Hk=",
"aarch64-linux": "sha256-+jfkGr4tuv/s/v890XaFTRqUxwA8H8KVMiys6sEXi/4=",
"aarch64-darwin": "sha256-FYtKluNazzuQcrr2iHm7B2pJfYrAHkbcj4kWs2FtBgc=",
"x86_64-darwin": "sha256-PpyWDkzb0p50US7Bs3m3A4PZ0JM3dccGgTKYN9JFYR0="
"x86_64-linux": "sha256-BZR0H2ZmkYNm7g0bx2Vm/15k1jZbtBQLuIkmZbrUepM=",
"aarch64-linux": "sha256-0WVQV0i95AZXtCm5/3txmINTAtenplnPH4A49oZ51aE=",
"aarch64-darwin": "sha256-ELjSy1HfKSk+VZaOIxgcZl1Imnga8rkKZjKu32bzzvg=",
"x86_64-darwin": "sha256-cwXZNYAiOXq63/gAuqJtSSptZUA7cTficVWINAw8iTM="
}
}
+11 -7
View File
@@ -10,6 +10,7 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -17,13 +18,14 @@
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
@@ -36,9 +38,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.3.1",
"@opentui/keymap": "0.3.1",
"@opentui/solid": "0.3.1",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -72,6 +74,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
@@ -84,7 +87,7 @@
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
"@lydell/node-pty": "1.2.0-beta.12"
}
},
"devDependencies": {
@@ -98,7 +101,7 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "4.13.1",
"sst": "catalog:",
"turbo": "2.8.13"
},
"dependencies": {
@@ -141,6 +144,7 @@
"@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",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch"
}
}
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
let releasePath!: () => void
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked
return route.fallback()
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
try {
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
} finally {
releasePath()
}
})
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -299,39 +300,13 @@ function readExpanded(element: Element) {
}
async function mockServer(page: Page, events: EventPayload[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event") return sse(route, events.splice(0))
if (
path === "/global/config" ||
path === "/config" ||
path === "/provider/auth" ||
path === "/mcp" ||
path === "/session/status"
)
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
return json(route, {})
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0),
})
}
@@ -372,24 +347,6 @@ function provider() {
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events: EventPayload[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
headers: { "access-control-allow-origin": "*" },
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
})
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
@@ -207,33 +208,12 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
}
async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, messages)
return json(route, {})
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: messages }),
})
}
@@ -282,19 +262,6 @@ function provider() {
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+9 -3
View File
@@ -18,6 +18,7 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
events?: () => unknown[]
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -43,7 +44,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -81,6 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
function sse(route: Route, events?: unknown[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.13",
"description": "",
"type": "module",
"exports": {
Binary file not shown.
+56 -34
View File
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -40,12 +41,14 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -69,7 +72,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
activeServer?: string
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -91,9 +94,26 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
@@ -152,11 +172,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@@ -279,11 +301,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
)
}
function ServerKey(props: ParentProps) {
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
{(key) => props.children(key)}
</Show>
)
}
@@ -296,31 +318,31 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServersProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
{() => (
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
)}
</ServerKey>
</ConnectionGate>
</ServersProvider>
</ServerProvider>
)
}
@@ -8,7 +8,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
+1 -1
View File
@@ -6,7 +6,7 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -5,20 +5,26 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useWslServers } from "@/context/wsl-servers"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface DialogSelectServerProps {
onNavigateHome?: () => void
}
interface ServerFormProps {
value: string
name: string
@@ -27,7 +33,6 @@ interface ServerFormProps {
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
@@ -44,15 +49,17 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
return conn.type === "sidecar" && conn.variant === "wsl"
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
const [defaultKey, defaultActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
@@ -60,52 +67,18 @@ function useDefaultServer() {
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
defaultActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -171,15 +144,18 @@ function ServerForm(props: ServerFormProps) {
)
}
export function DialogSelectServer() {
const navigate = useNavigate()
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const wslServers = useWslServers()
const defaultServer = useDefaultServer()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
disposed = true
})
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
@@ -189,7 +165,9 @@ export function DialogSelectServer() {
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
addWsl: {
showWizard: false,
},
editServer: {
id: undefined as string | undefined,
@@ -198,7 +176,6 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
@@ -210,7 +187,6 @@ export function DialogSelectServer() {
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
@@ -221,7 +197,6 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
status: undefined,
})
}
@@ -294,6 +269,32 @@ export function DialogSelectServer() {
},
}))
const removeWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.removeServer(key)
return key
},
onSuccess: async (key) => {
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
server.remove(key)
},
onError: (err) => showRequestError(language, err),
}))
const retryWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.startServer(key)
},
onError: (err) => showRequestError(language, err),
}))
const updateWslMutation = useMutation(() => ({
mutationFn: async (distro: string) => {
await platform.wslServers?.installOpencode(distro)
},
onError: (err) => showRequestError(language, err),
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
@@ -312,6 +313,32 @@ export function DialogSelectServer() {
})
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const wslState = () => wslServers.data
const healthPollKey = createMemo(() =>
items()
.map((conn) =>
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
)
.join("\n\n"),
)
const health = (key: ServerConnection.Key) => store.status[key]
const wslRuntime = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
}
const nonReadyWslServers = createMemo(() =>
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
)
const canRetryWsl = (conn: ServerConnection.Any) => {
const runtime = wslRuntime(conn)
return runtime?.kind === "failed" || runtime?.kind === "stopped"
}
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
const wslRuntimeLabel = (kind: string) => {
if (kind === "starting") return "Starting"
if (kind === "failed") return "Failed"
return "Stopped"
}
const sortedItems = createMemo(() => {
const list = items()
@@ -326,7 +353,7 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === 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(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
@@ -334,39 +361,60 @@ export function DialogSelectServer() {
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
const list = untrack(items)
await Promise.all(
items().map(async (conn) => {
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (disposed) return
setStore("status", reconcile(results))
}
createEffect(() => {
items()
healthPollKey()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const wslCheck = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return null
return wslState()?.opencodeChecks[conn.distro] ?? null
}
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
const nextKey = ServerConnection.key(conn)
const changed = server.key !== nextKey
const navigateHome = () => props.onNavigateHome?.()
const apply = () => {
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigateHome()
return
}
batch(() => {
navigateHome()
server.setActive(nextKey)
})
}
if (!changed) {
await apply()
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
apply()
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
@@ -377,25 +425,16 @@ export function DialogSelectServer() {
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
@@ -406,20 +445,15 @@ export function DialogSelectServer() {
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
if (store.addWsl.showWizard) return "add-wsl"
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@@ -433,9 +467,11 @@ export function DialogSelectServer() {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", false)
}
const startAdd = () => {
setStore("addWsl", "showWizard", false)
resetEdit()
setStore("addServer", {
showForm: true,
@@ -444,11 +480,11 @@ export function DialogSelectServer() {
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
setStore("addWsl", "showWizard", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@@ -457,10 +493,22 @@ export function DialogSelectServer() {
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
})
}
const startAddWsl = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", true)
}
const handleAddedWsl = async (distro: string) => {
const key = ServerConnection.Key.make(`wsl:${distro}`)
setStore("addWsl", "showWizard", false)
const conn = items().find((item) => ServerConnection.key(item) === key)
if (conn) await select(conn)
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
@@ -477,14 +525,22 @@ export function DialogSelectServer() {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const isAddWslMode = createMemo(() => mode() === "add-wsl")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
<span>
{isAddWslMode()
? "Add WSL server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
</span>
</div>
)
})
@@ -495,37 +551,126 @@ export function DialogSelectServer() {
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
}
return (
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Dialog
title={formTitle()}
fit={isAddWslMode()}
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
>
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
<Show
when={isAddWslMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<DialogWslServer onAdded={handleAddedWsl} />
</Show>
}
>
<Show when={nonReadyWslServers().length > 0}>
<div class="px-5">
<div class="bg-surface-base rounded-md overflow-hidden">
<For each={nonReadyWslServers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const retryable = () => canRetryWslRuntime(item.runtime.kind)
return (
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-critical-base": item.runtime.kind === "failed",
"bg-border-weak-base": item.runtime.kind !== "failed",
}}
/>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
<Show when={defaultServer.defaultKey() === key}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
{language.t("dialog.server.status.default")}
</span>
</Show>
<span class="text-12-regular text-text-weak truncate">
{wslRuntimeLabel(item.runtime.kind)}
</span>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<Show when={retryable()}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={retryable() || defaultServer.canDefault()}>
<DropdownMenu.Separator />
</Show>
<DropdownMenu.Item
onSelect={() => removeWslMutation.mutate(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}}
</For>
</div>
</div>
</Show>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@@ -534,7 +679,7 @@ export function DialogSelectServer() {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
key={(x) => ServerConnection.key(x)}
onSelect={(x) => {
if (x) void select(x)
}}
@@ -543,18 +688,35 @@ export function DialogSelectServer() {
>
{(i) => {
const key = ServerConnection.key(i)
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => defaultServer.canDefault() && (i.type === "http" || wsl)
const canRemove = () => i.type === "http" || wsl
const opencodeAction = () => {
const check = wslCheck(i)
if (!check) return null
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
return null
}
const updating = () => {
const job = wslState()?.job
return job?.kind === "install-opencode" && job.distro === wslDistro
}
return (
<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">
<ServerHealthIndicator health={store.status[key]} />
<ServerHealthIndicator health={health(key)} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
dimmed={blocked()}
status={health(key)}
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -562,12 +724,32 @@ export function DialogSelectServer() {
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={wsl && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
size="small"
disabled={!!wslState()?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => {
e.stopPropagation()
if (wslDistro) updateWslMutation.mutate(wslDistro)
}}
>
<Show when={updating()}>
<Spinner class="size-3.5 shrink-0" />
</Show>
{label()}
</Button>
)}
</Show>
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<Show when={i.type === "http" || i.type === "sidecar"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -579,35 +761,54 @@ export function DialogSelectServer() {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={i.type === "http"}>
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={wsl && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
<DropdownMenu.Separator />
</Show>
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => {
if (wsl) {
removeWslMutation.mutate(key)
return
}
void handleRemove(key)
}}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -621,17 +822,32 @@ export function DialogSelectServer() {
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
when={!isAddWslMode() && isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={!isAddWslMode()}>
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={canAddWsl()}>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAddWsl}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
Add WSL
</Button>
</Show>
</div>
</Show>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
@@ -0,0 +1,582 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedProbe = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const opencodeCheck = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = store.selectedDistro
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !store.selectedDistro) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = store.selectedDistro
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
createEffect(() => {
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || busy()) return
if (store.selectedDistro) return
if (existingServerDistros().has(distro.name)) return
setStore("selectedDistro", distro.name)
})
createEffect(() => {
const distros = installableDistros()
if (!distros.length) {
if (store.installTarget) setStore("installTarget", undefined)
return
}
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
setStore("installTarget", distros[0]!.name)
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
return state.runtime?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
const distro = store.selectedDistro
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
if (state.job?.kind === "distros") return "Listing distros..."
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (distro) return `Finishing setup for ${distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
const distro = store.selectedDistro
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
}
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = store.selectedDistro
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const finish = async () => {
const distro = store.selectedDistro
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return "Failed to load WSL state."
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">WSL</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
Install WSL
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
Relaunch OpenCode
</Button>
</div>
</Show>
<div class="flex items-center justify-end">
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
: "Checking distros..."}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">Default</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label="Install distro"
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => store.installTarget === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show
when={
selectedInstalled()?.version === 1 ||
distroUnavailableMessage() ||
distroMissingTools()
}
>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
Open terminal
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !store.selectedDistro}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !store.selectedDistro || !distroReady()}
onClick={() => setStore("step", "opencode")}
>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">OpenCode</div>
<div class="flex items-center gap-2">
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
Refresh
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
<div class="text-12-regular text-text-weak">
Version: {check().version ?? "unknown"}
<Show when={check().expectedVersion}>
{(expected) => <span>{` · desktop ${expected()}`}</span>}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
+488 -228
View File
@@ -4,6 +4,8 @@ import {
createEffect,
on,
Component,
splitProps,
For,
Show,
onCleanup,
createMemo,
@@ -11,7 +13,10 @@ import {
createResource,
Switch,
Match,
type ComponentProps,
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -26,12 +31,14 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -44,6 +51,7 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -68,14 +76,14 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
@@ -113,11 +121,9 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions()
const sync = useSync()
@@ -125,6 +131,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const files = useFile()
const prompt = usePrompt()
const layout = useLayout()
const server = useServer()
const comments = useComments()
const dialog = useDialog()
const providers = useProviders()
@@ -132,11 +139,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false }
const inset = 56
@@ -277,6 +286,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal",
applyingHistory: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
@@ -1303,91 +1316,124 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return "Ask anything, / for commands, @ for context..."
}
const modelControl = () => (
<Show when={!providersLoading()}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
paid: providers.paid().length > 0,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
model: local.model,
providerID: local.model.current()?.provider?.id,
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
},
}))
const newSession = () => props.variant === "new-session"
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
const currentWorktree = createMemo(() => {
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
return props.newSessionWorktree ?? MAIN_WORKTREE
return MAIN_WORKTREE
const projects = createMemo(() => layout.projects.list())
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
return projects().find(
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const worktreeLabel = (value: string) => {
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
return getFilename(value)
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
projectSearch: "",
})
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
restoreFocus()
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
selectProject(directory)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} />,
() => select(null),
)
})
}
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const projectPickerState = createMemo<ComposerPickerState>(() => ({
open: picker.projectOpen,
trigger: {
action: "prompt-project",
icon: "folder",
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
class: "max-w-[203px]",
style: control(),
onPress: () => setPicker("projectOpen", true),
},
search: picker.projectSearch,
searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"),
items: projectResults().map((project) => ({
icon: "folder",
label: displayName(project),
selected: selectedProject()?.worktree === project.worktree,
onSelect: () => selectProject(project.worktree),
})),
action: {
icon: "plus",
label: language.t("session.new.project.add"),
onSelect: () => {
setPicker("projectOpen", false)
void addProject()
},
},
onOpenChange: (open) => {
setPicker("projectOpen", open)
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
},
onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}))
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
style: control(),
onSelect: (value) => {
local.agent.set(value)
restoreFocus()
},
}))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
action: "prompt-project",
icon: "folder-add-left",
label: language.t("session.new.project.new"),
class: "max-w-[160px]",
style: control(),
onPress: () => void addProject(),
}))
return (
<div class="relative size-full flex flex-col gap-0">
@@ -1408,155 +1454,146 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={USE_V2_INPUT}>
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
editorRef?.focus()
<Match when={settings.general.newLayoutDesigns()}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action^="prompt-"]')) return
editorRef?.focus()
}}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
</div>
</div>
</div>
</div>
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipKeybind>
<Show when={newSession()}>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="sliders" size="small" />
</div>
<Select
size="normal"
options={worktrees()}
current={currentWorktree()}
label={worktreeLabel}
onSelect={(value) => {
if (value) props.onNewSessionWorktreeChange?.(value)
restoreFocus()
}}
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-workspace" }}
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</div>
</Show>
{modelControl()}
</TooltipKeybind>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
<Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</DockShellForm>
</DockShellForm>
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} />
</div>
</Show>
</div>
</Match>
<Match when>
<DockShellForm
@@ -1892,3 +1929,226 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
)
}
type ComposerPickerItemState = {
icon: IconProps["name"]
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = {
title: string
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
onSelect: (value: string | undefined) => void
}
type ComposerModelControlState = {
loading: boolean
paid: boolean
title: string
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
}
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
)
}
function ComposerModelControl(props: { state: ComposerModelControlState }) {
return (
<Show when={!props.state.loading}>
<Show
when={props.state.paid}
fallback={
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
}
@@ -1,6 +1,6 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
@@ -1,5 +1,5 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
status?: ServerHealth
version?: string
class?: string
nameClass?: string
versionClass?: string
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverName(props.conn))
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
const version = createMemo(() => props.version ?? props.status?.version)
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.conn.http.url
props.status?.version
version()
queueMicrotask(check)
})
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
<Show when={isWsl()}>
<span class="text-text-invert-weak">WSL</span>
</Show>
<Show when={version()}>
<span class="text-text-invert-weak">v{version()}</span>
</Show>
</span>
)
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show when={isWsl()}>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
</Show>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<Show when={version()}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
v{version()}
</span>
</Show>
}
@@ -5,7 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
@@ -24,7 +24,9 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
const OPEN_APPS = [
"vscode",
@@ -153,11 +155,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -231,6 +233,14 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@@ -311,193 +321,235 @@ export function SessionHeader() {
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
<Show
when={isDesktopV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
)
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-0">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</div>
)
}
@@ -1,76 +1,14 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const serverSync = useServerSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
const server = useServer()
const sync = useSync()
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = serverSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
const openProject = (directory: string | undefined) => {
if (!directory) return
if (directory === projectRoot()) return
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">
{props.children}
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
<Select
size="normal"
variant="ghost"
options={projects()}
current={projectRoot()}
label={getFilename}
onSelect={openProject}
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
/>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="branch" size="small" />
</div>
<Select
size="normal"
variant="ghost"
options={[branch()]}
current={branch()}
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
/>
</div>
</div>
</div>
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
@@ -7,7 +7,8 @@ import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
@@ -86,6 +87,7 @@ export const SettingsGeneral: Component = () => {
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
@@ -399,6 +401,24 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -444,18 +464,6 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -467,6 +475,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -781,7 +801,6 @@ export const SettingsGeneral: Component = () => {
</Show>
)
console.log(import.meta.env)
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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -803,7 +822,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
+176 -71
View File
@@ -1,17 +1,29 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
const IconButtonV2 = lazy(() =>
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
)
const TextInputV2 = lazy(() =>
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
)
const SettingsListV2 = lazy(() =>
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
)
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
@@ -257,7 +269,7 @@ function useKeyCapture(input: {
})
}
export const SettingsKeybinds: Component = () => {
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
@@ -371,85 +383,178 @@ export const SettingsKeybinds: Component = () => {
if (store.active) command.keybinds(true)
})
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="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<List>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</List>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return (
<Show
when={props.v2}
fallback={
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onChange={(v) => setStore("filter", v)}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<SettingsList>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span class="text-14-regular text-text-strong">{title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsList>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
<Show when={store.filter}>
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
</Show>
</div>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">{groups}</div>
</>
</Show>
)
}
@@ -2,7 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
@@ -0,0 +1,80 @@
import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
<TabsV2
orientation="vertical"
variant="settings"
defaultValue="general"
class="settings-v2"
data-component="settings-v2"
>
<TabsV2.List>
<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">
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
</div>
</div>
</div>
</div>
<div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
</TabsV2>
</Dialog>
)
}
@@ -0,0 +1,825 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneralV2: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
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 globalSync = useServerSync()
const globalSdk = useServerSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = globalSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const AdvancedSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRowV2>
<Show when={linux()}>
<SettingsRowV2
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
</Show>
)
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection />
<NotificationsSection />
<SoundsSection />
<UpdatesSection />
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</>
)
}
@@ -0,0 +1 @@
export { DialogSettings } from "./dialog-settings-v2"
@@ -0,0 +1,138 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/v2/switch-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 { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.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={() => list.clear()}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body settings-v2-models">
<Show
when={!list.grouped.loading}
fallback={
<div class="settings-v2-models-status">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="settings-v2-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
</For>
</Show>
</Show>
</div>
</>
)
}
@@ -0,0 +1,6 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-v2-list">{props.children}</div>
}
@@ -0,0 +1,20 @@
import type { Component, JSX } from "solid-js"
import "../settings-v2.css"
export interface SettingsRowV2Props {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
return (
<div data-component="settings-v2-row">
<div data-slot="settings-v2-row-copy">
<div data-slot="settings-v2-row-title">{props.title}</div>
<div data-slot="settings-v2-row-description">{props.description}</div>
</div>
<div data-slot="settings-v2-row-control">{props.children}</div>
</div>
)
}
@@ -0,0 +1,263 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "../dialog-connect-provider"
import { DialogSelectProvider } from "../dialog-select-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const globalSDK = useServerSDK()
const globalSync = useServerSync()
const providers = useProviders()
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disableProvider = async (providerID: string, name: string) => {
const before = globalSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
globalSync.set("config", "disabled_providers", next)
await globalSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
globalSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await globalSDK.client.auth
.remove({ providerID })
.then(async () => {
await globalSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
<div class="settings-v2-section" data-component="connected-providers-section">
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsListV2>
<Show
when={connected().length > 0}
fallback={
<div class="settings-v2-provider-empty">{language.t("settings.providers.connected.empty")}</div>
}
>
<For each={connected()}>
{(item) => (
<div class="settings-v2-provider-row group">
<div class="settings-v2-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-v2-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</ButtonV2>
</Show>
</div>
)}
</For>
</Show>
</SettingsListV2>
</div>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.popular")}</h3>
<SettingsListV2>
<For each={popular()}>
{(item) => (
<div class="settings-v2-provider-row">
<div class="settings-v2-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{item.name}</span>
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <p class="settings-v2-provider-description">{language.t(key())}</p>}
</Show>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
)}
</For>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</SettingsListV2>
<button
type="button"
class="settings-v2-providers-view-all"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
{language.t("dialog.provider.viewAll")}
</button>
</div>
</div>
</>
)
}
@@ -0,0 +1,515 @@
@import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css";
[data-component="settings-v2"] {
height: 100%;
}
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
.settings-v2-panel::-webkit-scrollbar {
display: none;
}
.settings-v2-tab-header {
position: sticky;
top: 0;
z-index: 10;
padding: 40px 40px 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-v2-tab-title {
font-size: 15px;
font-weight: 640;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-tab-body {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
padding: 0 40px 40px;
}
[data-slot="settings-v2-row-description"] a.settings-v2-link {
color: var(--v2-text-text-accent);
cursor: pointer;
text-decoration: none;
}
[data-slot="settings-v2-row-description"] a.settings-v2-link:hover {
color: var(--v2-text-text-accent-hover);
}
.settings-v2-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-v2-section-title {
padding-bottom: 8px;
font-size: 15px;
font-weight: 640;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: -4px;
margin-bottom: 0;
}
[data-component="settings-v2-list"] {
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
padding-inline: 20px;
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"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
[data-component="settings-v2-row"]:last-child {
border-bottom: none;
}
@media (min-width: 640px) {
[data-component="settings-v2-row"] {
flex-wrap: nowrap;
}
}
[data-slot="settings-v2-row-copy"] {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
[data-slot="settings-v2-row-title"] {
font-style: normal;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="settings-v2-row-description"] {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
[data-slot="settings-v2-row-control"] {
display: flex;
width: 100%;
justify-content: flex-end;
}
[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]),
[data-slot="settings-v2-row-control"] > [data-component="switch"] {
display: inline-flex;
align-items: center;
padding: 4px;
}
@media (min-width: 640px) {
[data-slot="settings-v2-row-control"] {
width: auto;
flex-shrink: 0;
}
}
[data-slot="settings-v2-row-control"] [data-component="text-input-v2"] {
width: 100%;
}
[data-component="settings-v2-dialog"] [data-component="select"][data-trigger-style="settings-v2"] {
width: fit-content;
max-width: 100%;
}
[data-component="settings-v2-dialog"]
[data-component="select"][data-trigger-style="settings-v2"]
[data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
background-color: var(--v2-background-bg-layer-01);
}
.settings-v2-nav-footer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
}
.settings-v2-nav-footer > span {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-legacy-panel {
height: 100%;
overflow: hidden;
}
.settings-v2-legacy-panel [data-component="dialog"] {
display: contents;
}
.settings-v2-provider-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-provider-row:last-child {
border-bottom: none;
}
@media (min-width: 640px) {
.settings-v2-provider-row {
flex-wrap: nowrap;
}
}
.settings-v2-providers [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-v2-provider-lead {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
}
.settings-v2-provider-lead:not(:has(.settings-v2-provider-copy)) {
align-items: center;
}
.settings-v2-provider-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-provider-main {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.settings-v2-provider-name {
font-size: 13px;
font-weight: 530;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-provider-description {
margin: 0;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-provider-empty {
padding-block: 20px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-provider-env-hint {
padding-right: 12px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
opacity: 0;
transition: opacity 200ms ease;
cursor: default;
}
.group:hover .settings-v2-provider-env-hint {
opacity: 1;
}
.settings-v2-providers-view-all {
margin-top: 20px;
padding: 0;
border: 0;
background: transparent;
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-accent);
cursor: pointer;
text-align: left;
}
.settings-v2-providers-view-all:hover {
color: var(--v2-text-text-accent-hover);
}
.settings-v2-tab-body.settings-v2-providers {
gap: 32px;
}
.settings-v2-tab-header:has(+ .settings-v2-tab-body.settings-v2-providers) {
padding-bottom: 32px;
}
.settings-v2-providers .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.settings-v2-providers .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 16px;
}
.settings-v2-tab-header.settings-v2-tab-header--stacked {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 32px;
}
.settings-v2-tab-header--stacked > .settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-tab-search {
position: relative;
width: 100%;
}
.settings-v2-tab-search [data-component="text-input-v2"] {
width: 100%;
}
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-right: 28px;
}
.settings-v2-tab-search-clear {
position: absolute;
top: 50%;
right: 6px;
z-index: 1;
transform: translateY(-50%);
}
.settings-v2-tab-body.settings-v2-models {
gap: 24px;
}
.settings-v2-models-group-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.settings-v2-models .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 16px;
}
.settings-v2-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0;
}
.settings-v2-models [data-slot="settings-v2-row-description"]:empty {
display: none;
}
.settings-v2-models [data-slot="settings-v2-row-copy"] {
gap: 0;
}
.settings-v2-models [data-slot="settings-v2-row-title"] {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-models-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-models-status-filter {
color: var(--v2-text-text-base);
}
.settings-v2-shortcuts .settings-v2-section {
gap: 16px;
}
.settings-v2-shortcuts .settings-v2-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 0;
padding-bottom: 0;
border-bottom: none;
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-shortcuts [data-component="settings-v2-list"] > div > span {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
.settings-v2-keybind-button {
box-sizing: border-box;
flex-shrink: 0;
padding: 6px 8px;
margin: -6px -8px;
border: 0;
border-radius: 2px;
background: transparent;
cursor: pointer;
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-feature-settings:
"tnum" on,
"lnum" on;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
}
.settings-v2-keybind-button:hover {
background-color: var(--v2-background-bg-layer-02);
}
.settings-v2-keybind-button:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-v2-keybind-button--active {
color: var(--v2-text-text-faint);
border-radius: 2px;
background-color: var(--v2-background-bg-layer-02);
}
.settings-v2-shortcuts-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-shortcuts-status-filter {
color: var(--v2-text-text-base);
}
@@ -3,20 +3,21 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { useLocation, useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, 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 { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useServers } from "@/context/servers"
const pollMs = 10_000
@@ -54,40 +55,6 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
@@ -166,14 +133,169 @@ const useMcpToggleMutation = () => {
}))
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverServerBody() {
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: servers.health[key],
blocked: servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={props.state.servers()}>
{(item) => {
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
const sync = useSync()
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const location = useLocation()
const fail = (err: unknown) => {
showToast({
variant: "error",
@@ -192,15 +314,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -226,7 +340,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -249,7 +363,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
const blocked = () => servers.health[key]?.healthy === false
return (
<button
type="button"
@@ -261,15 +375,23 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
props.close?.()
navigate("/")
queueMicrotask(() => server.setActive(key))
const activate = () => {
if (location.pathname !== "/") {
setTimeout(activate, 16)
return
}
setTimeout(() => server.setActive(key), 0)
}
setTimeout(activate, 0)
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerHealthIndicator health={servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={health[key]}
status={servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
+145 -8
View File
@@ -1,19 +1,24 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useServers } from "@/context/servers"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -21,7 +26,8 @@ export function StatusPopover() {
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
const serverHealthy = () => servers.health[server.key]?.healthy === true
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
return (
<Popover
@@ -43,10 +49,9 @@ export function StatusPopover() {
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base":
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
/>
</div>
@@ -62,9 +67,141 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} />
<Body shown={shown} close={() => setShown(false)} />
</Suspense>
</Show>
</Popover>
)
}
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
describe("titlebar session events", () => {
test("reads valid removed session tab details", () => {
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
}),
),
).toEqual({
directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"],
})
})
test("ignores invalid removed session tab details", () => {
expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: [] },
}),
),
).toBeUndefined()
})
})
@@ -0,0 +1,29 @@
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
directory: string
sessionIDs: string[]
}
export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
}
export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
if (!(event instanceof CustomEvent)) return undefined
const detail: unknown = event.detail
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
directory: detail.directory,
sessionIDs,
}
}
+170 -75
View File
@@ -6,10 +6,10 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -20,11 +20,16 @@ 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 { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener"
import { StatusPopover } from "./status-popover"
import { SDKProvider } from "@/context/sdk"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -51,7 +56,6 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -71,6 +75,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -81,7 +86,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -115,7 +120,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
visible: version !== undefined,
installing: props.update?.installing() ?? false,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(),
}
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
statusVisible: !params.dir && settings.general.showStatus(),
statusLabel: language.t("status.popover.trigger"),
}))
const back = () => {
const next = backPath(history)
@@ -202,8 +223,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
@@ -219,7 +240,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onDblClick={maximize}
>
<Switch>
<Match when={USE_V2_TITLEBAR}>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
@@ -261,7 +282,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
},
removeTab: (href: string) => {
startTransition(() => {
void startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
@@ -274,11 +295,45 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
})
},
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 nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
const detail = readSessionTabsRemovedDetail(event)
if (!detail) return
tabsStoreActions.removeSessions(detail)
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
@@ -316,22 +371,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return true
}
makeEventListener(
document,
"keydown",
(event) => {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "w") return
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
const openNewTab = () => navigate(newSessionHref())
event.preventDefault()
event.stopPropagation()
},
{ capture: true },
)
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
command.register(() => {
const commands = [
{
id: "tab.new",
category: "tab",
title: language.t("command.session.new"),
keybind: "mod+t",
hidden: true,
onSelect: openNewTab,
},
{
id: "tab.close",
category: "tab",
title: language.t("command.tab.close"),
keybind: "mod+w",
hidden: true,
onSelect: closeActiveTab,
},
{
id: `tab.prev`,
category: "tab",
@@ -435,8 +496,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
title={tab.info.title}
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
sessionId={tab.info.id}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
@@ -465,16 +526,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</Show>
<div class="min-w-0 flex-1" />
</div>
<Show when={currentSessionTab()?.dir} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</SDKProvider>
)}
</Show>
<TitlebarUpdatePill update={props.update} />
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
@@ -641,28 +693,50 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const version = () => props.update?.version()
type TitlebarUpdatePillState = {
visible: boolean
installing: boolean
label: string
ariaLabel: string
title?: string
onInstall: () => void
}
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
statusVisible: boolean
statusLabel: string
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<Show when={version() !== undefined}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
onClick={() => props.update?.install()}
disabled={props.update?.installing()}
aria-label={language.t("toast.update.action.installRestart")}
title={version() ? `Update ${version()}` : undefined}
>
Update
</button>
</Show>
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
return
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
return (
<Show when={props.state.visible}>
<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"
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-label={props.state.ariaLabel}
title={props.state.title}
>
{props.state.label}
</button>
</Show>
)
}
function TabNavItem(props: {
@@ -670,27 +744,39 @@ function TabNavItem(props: {
title: string
project?: LocalProject
directory: string
sessionId: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
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)] pl-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()}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden 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"
>
<ProjectTabAvatar project={props.project} directory={props.directory} />
<span class="text-clip">{props.title}</span>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} />
</span>
<span class="min-w-0 flex-1 truncate">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div class="absolute right-0 inset-y-0 flex flex-row items-center overflow-hidden rounded-r-[6px] pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 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={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
@@ -699,8 +785,8 @@ function TabNavItem(props: {
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
@@ -708,31 +794,44 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId)
return (
<AvatarV2
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project?.icon?.color)}
class="size-4 rounded"
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<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)]">
<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)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
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-[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">{props.title}</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
@@ -742,11 +841,7 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
@@ -2,8 +2,8 @@ import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
+34 -19
View File
@@ -8,8 +8,8 @@ import {
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { createServerSyncContext, useServerSync } from "./server-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
@@ -34,6 +34,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const isNotFound = (error: unknown) =>
error instanceof Error &&
typeof error.cause === "object" &&
error.cause !== null &&
(error.cause as { status?: unknown }).status === 404
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
@@ -172,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory))
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
@@ -347,6 +353,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
})
})
})
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => {
setMeta(
produce((draft) => {
@@ -458,22 +468,27 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
: retry(() => client.session.get({ sessionID }))
.then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq =
cached && !opts?.force
+1 -1
View File
@@ -1,7 +1,7 @@
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk"
@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({
directory: "/project",
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
})
})
@@ -9,7 +9,7 @@ import type {
Session,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
export async function bootstrapDirectory(input: {
directory: string
mcp: boolean
sdk: OpencodeClient
store: Store<State>
setStore: SetStoreFunction<State>
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
@@ -6,6 +6,7 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -48,12 +49,21 @@ beforeAll(async () => {
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: () => [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
],
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options)
return {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
},
}))
createChildStoreManager = (await import("./child-store")).createChildStoreManager
@@ -73,6 +83,7 @@ describe("createChildStoreManager", () => {
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
@@ -103,6 +114,7 @@ describe("createChildStoreManager", () => {
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
@@ -121,4 +133,76 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("provides the requested directory while the path query is pending", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
expect(store.path.directory).toBe("/project")
expect(store.path.worktree).toBe("")
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
const mcpLoads: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp(directory) {
mcpLoads.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(4)
const query = querySingles[offset + 1]
if (!query) throw new Error("query required")
expect(query().enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(query().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project")
expect(query().enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false)
} finally {
dispose()
}
})
})
@@ -1,4 +1,4 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -14,7 +14,7 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@@ -92,6 +95,24 @@ export function createChildStoreManager(input: {
})
}
function disposeChild(key: DirectoryKey) {
const dispose = disposers.get(key)
if (!key || !children[key]) return false
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
disposers.delete(key)
delete children[key]
input.onDispose(key)
if (dispose) {
dispose()
}
return true
}
function disposeDirectory(directory: DirectoryKey) {
const key = directory
if (
@@ -106,18 +127,13 @@ export function createChildStoreManager(input: {
return false
}
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(key)
return disposeChild(key)
}
function disposeAll() {
for (const directory of Object.keys(children)) {
disposeChild(directoryKey(directory))
}
delete children[key]
input.onDispose(key)
return true
}
function runEviction(skip?: string) {
@@ -173,15 +189,12 @@ export function createChildStoreManager(input: {
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
input.queryOptions.path(key),
input.queryOptions.mcp(key),
input.queryOptions.lsp(key),
input.queryOptions.providers(key),
],
}))
const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const child = createStore<State>({
project: "",
@@ -198,9 +211,9 @@ export function createChildStoreManager(input: {
},
config: {},
get path() {
if (pathQuery.isLoading || !pathQuery.data)
return { state: "", config: "", worktree: "", directory: "", home: "" }
return pathQuery.data
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
if (pathQuery.isLoading) return EMPTY
return pathQuery.data ?? EMPTY
},
status: "loading" as const,
agent: [],
@@ -236,6 +249,7 @@ export function createChildStoreManager(input: {
})
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -274,6 +288,7 @@ export function createChildStoreManager(input: {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -284,6 +299,7 @@ export function createChildStoreManager(input: {
function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -291,6 +307,19 @@ export function createChildStoreManager(input: {
return childStore
}
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
if (mcpDirectories.has(key)) return
mcpDirectories.add(key)
mcpToggles.get(key)?.(true)
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
mcpToggles.get(key)?.(false)
}
function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
@@ -330,7 +359,10 @@ export function createChildStoreManager(input: {
pin,
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
disposeAll,
runEviction,
vcsCache,
metaCache,
@@ -98,6 +98,7 @@ export type IconCache = {
export type ChildOptions = {
bootstrap?: boolean
mcp?: boolean
}
export type DirState = {
+13
View File
@@ -12,6 +12,9 @@ import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
export type { ProjectAvatarVariant }
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_SIDEBAR_WIDTH = 344
@@ -33,6 +36,16 @@ export function getAvatarColors(key?: string) {
}
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
+84 -5
View File
@@ -20,6 +20,88 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
@@ -75,11 +157,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
+23 -7
View File
@@ -1,5 +1,5 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { retry } from "@opencode-ai/core/util/retry"
type GlobalStore = {
ready: boolean
@@ -205,6 +206,19 @@ export function createServerSyncContext() {
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void retry(() =>
sdkFor(directory)
.command.list()
.then((x) => setStore("command", x.data ?? [])),
).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
},
onDispose: (directory) => {
const key = directoryKey(directory)
queue.clear(key)
@@ -311,6 +325,7 @@ export function createServerSyncContext() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
@@ -382,11 +397,7 @@ export function createServerSyncContext() {
onCleanup(() => {
queue.dispose()
})
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory))
}
})
onCleanup(children.disposeAll)
onMount(() => {
if (typeof requestAnimationFrame === "function") {
@@ -437,6 +448,7 @@ export function createServerSyncContext() {
},
child: children.child,
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
@@ -454,7 +466,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
return {
...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir),
directoryKey,
),
}
},
})
+27 -65
View File
@@ -1,8 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -37,24 +36,30 @@ export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
}
return [...deduped.values()]
@@ -122,13 +127,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
@@ -146,40 +145,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(conn)
.then((next) => {
if (!alive) return
setState("healthy", next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
return () => {
alive = false
clearInterval(interval)
}
}
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
createEffect(() => {
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = state.active
})
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
@@ -209,20 +186,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -230,12 +193,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const isLocal = createMemo(() => {
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
})
return {
ready: isReady,
healthy,
isLocal,
get key() {
return state.active
+20
View File
@@ -0,0 +1,20 @@
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,
}
},
})
+12
View File
@@ -32,6 +32,8 @@ export interface Settings {
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -53,6 +55,7 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -117,6 +120,7 @@ const defaultSettings: Settings = {
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
@@ -236,6 +240,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+35
View File
@@ -0,0 +1,35 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { WslServersPlatform, WslServersState } from "./platform"
import { usePlatform } from "./platform"
const wslServersQueryKey = ["platform", "wslServers"] as const
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
name: "WslServers",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => {
const api = platform.wslServers
return queryOptions<WslServersState>({
queryKey: wslServersQueryKey,
queryFn: api ? () => api.getState() : skipToken,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
createEffect(() => {
const api = platform.wslServers
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(wslServersQueryKey, event.state)
})
onCleanup(off)
})
return query
},
})
+12 -1
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
@@ -530,7 +531,10 @@ export const dict = {
"home.projects": "Projects",
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}",
"home.sessions.empty": "No sessions found",
"home.sessions.empty.description": "Start a new session for this project",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
@@ -584,6 +588,9 @@ export const dict = {
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything",
"session.new.project.new": "New project",
"session.new.project.search": "Search projects",
"session.new.project.add": "Add project",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
@@ -764,7 +771,7 @@ export const dict = {
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -773,6 +780,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -784,6 +793,8 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+1 -2
View File
@@ -759,8 +759,7 @@ export const dict = {
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description":
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
+2
View File
@@ -502,6 +502,8 @@ export const dict = {
"home.projects": "项目",
"home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话",
"home.sessions.search.sessions": "会话",
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
"home.sessions.empty": "未找到会话",
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
+7
View File
@@ -8,6 +8,13 @@
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/assets/Inter.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@layer components {
@keyframes session-progress-whip {
0% {
+19 -1
View File
@@ -2,6 +2,24 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { useWslServers } from "./context/wsl-servers"
export {
type DisplayBackend,
type FatalRendererErrorLog,
type Platform,
PlatformProvider,
type WslDistroProbe,
type WslInstalledDistro,
type WslJob,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslRuntimeCheck,
type WslServerConfig,
type WslServerItem,
type WslServerRuntime,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
+1 -1
View File
@@ -1,5 +1,5 @@
import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
+627 -113
View File
@@ -1,15 +1,18 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
@@ -18,23 +21,28 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useServers } from "@/context/servers"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/core/util/path"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerHealthIndicator } from "@/components/server/server-row"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
@@ -49,9 +57,55 @@ type HomeSessionGroup = {
sessions: HomeSessionRecord[]
}
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SEARCH_RESULT_ROW =
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_SEARCH_RESULT_TITLE =
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
const HOME_SEARCH_RESULT_META =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
function buildHomeSessionRecords(input: {
sync: ReturnType<typeof useServerSync>
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
return [
...new Map(
input
.projectDirectories()
.flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return {
session,
project,
projectName: displayName(project),
}
})
}
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
export default function Home() {
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
@@ -62,16 +116,22 @@ function HomeDesign() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const command = useCommand()
const notification = useNotification()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
project: undefined as string | undefined,
searchFocused: false,
})
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
return [project.worktree, ...(project.sandboxes ?? [])]
if (!project) return projects().flatMap(directories)
return directories(project)
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
@@ -85,36 +145,46 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, projects(), projectByID())
if (!project) return []
return {
session,
project,
projectName: displayName(project),
}
})
.filter((record) => {
const value = search().toLowerCase()
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync,
projectDirectories,
projects,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
}
function selectSearchSession(session: Session) {
openSession(session)
closeSearch()
}
command.register("home", () => [
{
id: "home.sessions.search.focus",
title: language.t("home.sessions.search.placeholder"),
keybind: "mod+f",
hidden: true,
onSelect: () => focusSessionSearch?.(),
},
])
function selectProject(directory: string) {
if (!projects().some((project) => project.worktree === directory)) return
setState("project", directory)
setState("project", state.project === directory ? undefined : directory)
}
function addProject(directory: string) {
@@ -134,6 +204,28 @@ function HomeDesign() {
navigate(`/${base64Encode(project.worktree)}/session`)
}
function openProjectNewSession(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
function editProject(project: LocalProject) {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
function unseenCount(project: LocalProject) {
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
}
function clearNotifications(project: LocalProject) {
directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
}
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
@@ -167,7 +259,7 @@ function HomeDesign() {
}
function openSettings() {
void import("@/components/dialog-settings").then((x) => {
void import("@/components/settings-v2").then((x) => {
dialog.show(() => <x.DialogSettings />)
})
}
@@ -178,22 +270,37 @@ function HomeDesign() {
projects={projects()}
selected={selectedProject()?.worktree}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={() => void chooseProject()}
editProject={editProject}
closeProject={(directory) => {
layout.projects.close(directory)
if (state.project === directory) setState("project", undefined)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<section class="min-w-0 flex-1 flex flex-col pt-12" aria-label={language.t("sidebar.project.recentSessions")}>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
@@ -232,14 +339,20 @@ function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
const servers = useServers()
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
@@ -251,46 +364,49 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<For each={props.projects}>
{(project) => (
<button
type="button"
data-component="home-project-row"
class={HOME_PROJECT_NAV_ROW}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
data-selected={props.selected === project.worktree ? "" : undefined}
aria-current={props.selected === project.worktree ? "page" : undefined}
onClick={() => props.selectProject(project.worktree)}
>
<HomeProjectAvatar project={project} />
<span>{displayName(project)}</span>
</button>
)}
</For>
</Show>
</div>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<Show when={servers.list().length > 1} fallback={<HomeProjectList {...props} />}>
<For each={servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!servers.health[key]?.healthy
const [state, setState] = createStore({ open: true })
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={!healthy()}
onClick={() => setState("open", !state.open)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="shrink-0 text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={state.open}
/>
</Show>
</button>
<Show when={healthy() && state.open}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} />
</Show>
</div>
)
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span>{props.language.t("sidebar.settings")}</span>
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
@@ -298,55 +414,444 @@ function HomeProjectColumn(props: {
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span>{props.language.t("sidebar.help")}</span>
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
</aside>
)
}
function HomeProjectList(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
return (
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
selected={props.selected === project.worktree}
unseenCount={props.unseenCount(project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</Show>
)
}
function HomeProjectRow(props: {
project: LocalProject
selected: boolean
unseenCount: number
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
onClick={() => props.selectProject(props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<MenuV2
gutter={4}
modal={false}
placement="bottom-end"
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.openNewSession(props.project.worktree)}
/>
</div>
</div>
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project))
return (
<AvatarV2
<ProjectAvatar
fallback={name()}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project.icon?.color)}
class="size-4 rounded"
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)
}
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
function HomeSessionSearch(props: {
value: string
placeholder: string
open: boolean
loading: boolean
results: HomeSessionRecord[]
noResultsLabel: string
bindFocus: (focus: () => void) => void
onInput: (value: string) => void
onFocus: () => void
onClose: () => void
onSelect: (session: Session) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ active: "" })
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let listRef: HTMLDivElement | undefined
const focusInput = () => {
input?.focus()
props.onFocus()
}
onMount(() => {
props.bindFocus(focusInput)
})
const syncActive = (results: HomeSessionRecord[]) => {
if (results.length === 0) {
setStore("active", "")
return
}
if (!results.some((record) => homeSessionSearchKey(record) === store.active)) {
setStore("active", homeSessionSearchKey(results[0]))
}
}
createEffect(() => syncActive(props.results))
createEffect(
on(
() => props.value,
() => syncActive(props.results),
),
)
const scrollActiveIntoView = () => {
const key = store.active
if (!key || !listRef) return
const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`)
element?.scrollIntoView({ block: "nearest" })
}
const moveActive = (delta: number) => {
const results = props.results
if (results.length === 0) return
const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active)
const start = index === -1 ? 0 : index
const next = (start + delta + results.length) % results.length
setStore("active", homeSessionSearchKey(results[next]))
scrollActiveIntoView()
}
const selectActive = () => {
const record = props.results.find((item) => homeSessionSearchKey(item) === store.active)
if (!record) return
props.onSelect(record.session)
}
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!props.open) return
const target = event.target
if (!(target instanceof Node)) return
if (root?.contains(target)) return
props.onClose()
}),
)
return (
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
<IconV2 name="magnifying-glass" size="small" />
<input
class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.value}
placeholder={props.placeholder}
aria-label={props.placeholder}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
</label>
<div class="ml-4 mr-2 w-[calc(100%_-_24px)]">
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<Show when={props.open}>
<div
data-component="home-session-search-panel"
class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{
top: "-6px",
left: "-6px",
width: "calc(100% + 14px)",
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2">
<Show
when={!props.loading}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
</div>
}
>
<Show
when={props.results.length > 0}
fallback={
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.noResultsLabel}
</p>
}
>
<div class="flex flex-col">
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{language.t("home.sessions.search.sessions")}
</p>
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto">
<For each={props.results}>
{(record) => (
<HomeSessionSearchResultRow
record={record}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
/>
)}
</For>
</div>
</div>
</Show>
</Show>
</div>
</div>
</div>
</Show>
<label
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
classList={{
"bg-v2-background-bg-deep focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
!props.open,
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
}}
>
<IconV2 name="magnifying-glass" />
<input
ref={input}
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.value}
placeholder={props.placeholder}
aria-label={props.placeholder}
aria-expanded={props.open}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
store.active && props.open ? `home-session-search-option-${store.active}` : undefined
}
onFocus={() => props.onFocus()}
onInput={(event) => props.onInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
input?.blur()
return
}
if (!props.open || props.results.length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.value}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.placeholder}
onClick={() => {
props.onClose()
input?.focus()
}}
/>
</Show>
</label>
</div>
</div>
)
}
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
}) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
const key = () => homeSessionSearchKey(props.record)
return (
<button
type="button"
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
role="option"
aria-selected={props.selected}
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
}}
onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)}
>
<Show
when={showStatus()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={props.record.projectName}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
</button>
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2
data-action="home-new-session"
variant="ghost"
variant="ghost-muted"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
class="h-7 px-2 [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
@@ -358,10 +863,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const serverSync = useServerSync()
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false })
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
@@ -385,7 +890,14 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
>
<Show when={showStatus()}>
<Show
when={showStatus()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
@@ -465,6 +977,7 @@ function LegacyHome() {
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
const servers = useServers()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
@@ -475,11 +988,12 @@ function LegacyHome() {
})
const serverDotClass = createMemo(() => {
const healthy = server.healthy()
const healthy = servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
function openProject(directory: string) {
layout.projects.open(directory)
@@ -498,7 +1012,7 @@ function LegacyHome() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -519,7 +1033,7 @@ function LegacyHome() {
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
>
<div
classList={{
+170 -164
View File
@@ -34,7 +34,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
@@ -89,8 +90,6 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -129,6 +128,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => setV2Toast(newDesign()))
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
@@ -152,9 +153,10 @@ export default function Layout(props: ParentProps) {
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_NEW_DESIGN,
autoselect: !initialDirectory && !newDesign(),
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1142,7 +1144,7 @@ export default function Layout(props: ParentProps) {
},
]
if (!USE_NEW_DESIGN)
if (!newDesign())
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
@@ -1222,13 +1224,16 @@ export default function Layout(props: ParentProps) {
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
})
}
function openSettings() {
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
const module = settings.general.newLayoutDesigns()
? import("@/components/settings-v2")
: import("@/components/dialog-settings")
void module.then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
@@ -1475,7 +1480,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -1821,7 +1826,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2363,179 +2368,180 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_NEW_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<ToastRegion v2={newDesign()} />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
data-component="sidebar-nav-desktop"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</main>
</div>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
{import.meta.env.DEV && <DebugBar />}
<ToastRegion v2={newDesign()} />
</div>
<Toast.Region />
</div>
</Show>
)
}
@@ -0,0 +1,24 @@
import { createMemo, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const hasPermissions = createMemo(() => {
const [store] = globalSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0)
const loading = createMemo(() => {
if (hasPermissions()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
return store.session_working(sessionId())
})
return { unread, loading }
}
+6 -9
View File
@@ -28,7 +28,7 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -198,6 +197,7 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -265,7 +265,7 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1663,7 +1663,6 @@ export default function Page() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
@@ -1799,16 +1798,14 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView worktree={newSessionWorktree()}>
{composerRegion("inline")}
</NewSessionDesignView>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -17,6 +17,7 @@ import type { SessionComposerState } from "@/pages/session/composer/session-comp
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -25,7 +26,6 @@ export function SessionComposerRegion(props: {
placement?: "dock" | "inline"
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
@@ -151,8 +151,9 @@ export function SessionComposerRegion(props: {
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"max-w-[720px] px-0": props.placement === "inline",
"w-full pointer-events-auto": true,
"px-3": props.placement !== "inline",
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
@@ -265,7 +266,6 @@ export function SessionComposerRegion(props: {
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
@@ -2,7 +2,7 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
@@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
+1 -1
View File
@@ -11,7 +11,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
@@ -48,7 +48,7 @@ import type {
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -65,6 +65,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { makeTimer } from "@solid-primitives/timer"
@@ -861,7 +862,9 @@ export function MessageTimeline(props: {
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync.session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
})
.catch((err) => {
showToast({
@@ -892,42 +895,46 @@ export function MessageTimeline(props: {
if (!result) return false
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync.data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
for (const id of removed) {
sync.session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
return true
}
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
})
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +1,6 @@
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
}
@@ -58,12 +58,8 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
@@ -207,7 +203,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
@@ -13,7 +13,7 @@ import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast"
import { showToast } from "@/utils/toast"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
@@ -70,10 +70,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
const id = params.id
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createRefCountMap } from "./refcount"
import { pathKey } from "./path-key"
describe("createRefCountMap", () => {
test("removes an item after its last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
)
const first = createRoot((dispose) => {
map("/project")
return dispose
})
const second = createRoot((dispose) => {
map("/project")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["/project"])
})
test("keeps equivalent path consumers until the last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
pathKey,
)
const first = createRoot((dispose) => {
map("C:\\repo")
return dispose
})
const second = createRoot((dispose) => {
map("C:/repo/")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["C:/repo"])
})
})
+15 -9
View File
@@ -1,26 +1,32 @@
import { onCleanup } from "solid-js"
export function createRefCountMap<T>(create: (key: string) => T) {
export function createRefCountMap<T>(
create: (key: string) => T,
remove?: (key: string) => void,
identity: (key: string) => string = (key) => key,
) {
const items = new Map<string, T>()
const refCounts = new Map<string, number>()
return (key: string) => {
const id = identity(key)
onCleanup(() => {
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
if (refCounts.get(key) === 0) {
items.delete(key)
refCounts.delete(key)
refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
if (refCounts.get(id) === 0) {
remove?.(id)
items.delete(id)
refCounts.delete(id)
}
})
const cached = items.get(key)
const cached = items.get(id)
if (cached) {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
return cached
}
const item = create(key)
items.set(key, item)
refCounts.set(key, 1)
items.set(id, item)
refCounts.set(id, 1)
return item
}
}
+39 -1
View File
@@ -1,6 +1,8 @@
import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string }
@@ -92,6 +94,8 @@ export async function checkServerHealth(
return attempt(0).finally(() => timeout?.clear?.())
}
const pollMs = 10_000
export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
@@ -111,3 +115,37 @@ export function useCheckServerHealth() {
return promise
}
}
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
+10 -14
View File
@@ -1,6 +1,6 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot, onCleanup, type JSXElement } from "solid-js"
import type { JSXElement } from "solid-js"
type DragEvent = { draggable?: { id?: unknown } }
@@ -27,20 +27,16 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer = createTransformer(transformerId, axis)
const dispose = createRoot((dispose) => {
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return dispose
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
onCleanup(dispose)
return null
}
+34
View File
@@ -0,0 +1,34 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
export function setV2Toast(value: boolean) {
v2 = value
}
export function ToastRegion(props: { v2: boolean }) {
if (props.v2) return <ToastV2.Region />
return <Toast.Region />
}
export function showToast(options: ToastOptions | string) {
if (!v2) return showLegacyToast(options)
if (typeof options === "string") return showToastV2(options)
return showToastV2({
...options,
icon: resolveIcon(options.icon, options.variant),
actions: options.actions?.map((action) => ({
...action,
variant: action.onClick === "dismiss" ? "secondary" : "primary",
})),
})
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
return <Icon name={name} />
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.15.13",
"private": true,
"type": "module",
"license": "MIT",
"bin": {
"opencode": "./src/index.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { EOL } from "os"
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
export const AgentsCommand = Command.make("agents", {}, () =>
Effect.gen(function* () {
const svc = {
plugin: yield* PluginBoot.Service,
agent: yield* AgentV2.Service,
}
yield* svc.plugin.wait()
const agents = yield* svc.agent.all()
process.stdout.write(
JSON.stringify(
agents.sort((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
}).pipe(
Effect.provide(
LocationServiceMap.get({
directory: AbsolutePath.make(process.cwd()),
}),
),
),
).pipe(Command.withDescription("List all agents"))
+7
View File
@@ -0,0 +1,7 @@
import * as Command from "effect/unstable/cli/Command"
import { AgentsCommand } from "./agents"
export const DebugCommand = Command.make("debug").pipe(
Command.withDescription("Debugging and troubleshooting tools"),
Command.withSubcommands([AgentsCommand]),
)
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bun
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Command from "effect/unstable/cli/Command"
import { DebugCommand } from "./debug"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
const cli = Command.make("opencode", {}, () => Effect.void).pipe(
Command.withDescription("OpenCode command line interface"),
Command.withSubcommands([DebugCommand]),
)
const layer = Layer.mergeAll(LocationServiceMap.layer, NodeServices.layer)
Command.run(cli, { version: "local" }).pipe(Effect.provide(layer), Effect.scoped, NodeRuntime.runMain)
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
}
}

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