Compare commits

..

191 Commits

Author SHA1 Message Date
opencode 056186225b release: v1.1.36 2026-01-25 17:29:26 +00:00
GitHub Action b982ab2fbc chore: generate 2026-01-25 17:26:11 +00:00
adamelmore 9a89cd91d7 fix(app): line selection styling 2026-01-25 11:25:31 -06:00
adamelmore 65ac318282 fix(app): user message fade 2026-01-25 11:25:31 -06:00
Ryan Vogel e491f5cc16 fix(web): add & fix the download button (#10566) 2026-01-25 12:20:39 -05:00
ishaksebsib ebe86e40a0 fix(tui): prevent crash when theme search returns no results (#10565) 2026-01-25 12:11:49 -05:00
Aiden Cline 9407a6fd7c ignore: rm STYLE_GUIDE, consolidate in AGENTS.md 2026-01-25 11:56:21 -05:00
GitHub Action d75dca29e9 chore: generate 2026-01-25 16:49:03 +00:00
adamelmore 471fc06f01 chore(app): visual cleanup 2026-01-25 10:48:20 -06:00
adamelmore 4c2d597ae6 fix(app): line selection colors 2026-01-25 10:30:10 -06:00
adamelmore 2b07291e17 fix(app): scroll to comment on click 2026-01-25 10:30:10 -06:00
GitHub Action d25120680d chore: generate 2026-01-25 14:07:33 +00:00
Devin Griffin a900c89245 fix(app): mobile horizontal scrolling due to session stat btn (#10487) 2026-01-25 08:06:56 -06:00
Rahul A Mistry caecc7911d fix(app): cursor on resize (#10293) 2026-01-25 08:04:25 -06:00
adamelmore f7a4cdcd32 fix(app): no default model crash 2026-01-25 07:01:36 -06:00
adamelmore e9152b174f fix(app): comment line placement in diffs 2026-01-25 06:50:27 -06:00
adamelmore dcc8d1a638 perf(app): performance improvements 2026-01-25 06:43:27 -06:00
adamelmore ddc4e89359 fix(app): cleanup comment component usage 2026-01-25 06:20:50 -06:00
GitHub Action a5c058e584 ignore: update download stats 2026-01-25 2026-01-25 12:05:15 +00:00
Michael Yochpaz 0bc4a43320 fix(provider): enable thinking for google-vertex-anthropic models (#10442)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-24 23:42:29 -05:00
Aiden Cline e2d0d85d93 test: fix 2026-01-24 23:42:12 -05:00
Aiden Cline 2917a2fa61 test: fix 2026-01-24 23:41:47 -05:00
GitHub Action 12473561ba chore: generate 2026-01-25 04:12:09 +00:00
Aiden Cline 397ee419d1 tweak: make question valdiation more lax to avoid tool call failures 2026-01-24 23:11:21 -05:00
Github Action b1072053ba chore: update nix node_modules hashes 2026-01-25 04:02:46 +00:00
GitHub Action a64f8d1b11 chore: generate 2026-01-25 04:01:25 +00:00
Aiden Cline 7f55a9736d chore: bump hey api 2026-01-24 23:00:40 -05:00
GitHub Action 460513a835 chore: generate 2026-01-25 00:37:59 +00:00
opencode 33298e8775 release: v1.1.35 2026-01-25 00:37:58 +00:00
adamelmore 2f9f588f77 fix(app): submit button state 2026-01-24 18:34:42 -06:00
Github Action d6efb797b5 chore: update nix node_modules hashes 2026-01-25 00:33:10 +00:00
Rahul A Mistry 399fec770f fix(app): markdown rendering with morphdom for better dom functions (#10373) 2026-01-24 18:29:58 -06:00
adamelmore 8d1a66d043 fix(app): unnecessary suspense flash 2026-01-24 17:17:44 -06:00
GitHub Action e1fe86e6d7 chore: generate 2026-01-24 23:10:03 +00:00
David Hill 93e948ae12 fix(ui): ensure comment popover appears above other comment icons 2026-01-24 23:09:22 +00:00
David Hill 8714b1a3ac add active state to comment cards in prompt input 2026-01-24 23:09:22 +00:00
David Hill 4ded06f05d update: theme variables 2026-01-24 23:09:22 +00:00
GitHub Action 6d0fecb985 chore: generate 2026-01-24 23:02:21 +00:00
adamelmore e223d1a0e5 fix: type error 2026-01-24 17:01:37 -06:00
adamelmore 3fdd6ec120 fix(app): terminal clone needs remount 2026-01-24 16:58:43 -06:00
adamelmore 2f1be914cd fix(app): remove terminal connection error overlay 2026-01-24 16:58:43 -06:00
GitHub Action 1269766cb8 chore: generate 2026-01-24 22:58:15 +00:00
adamelmore ae11cad13b fix: type error 2026-01-24 16:57:38 -06:00
adamelmore 10d227b8d6 fix(ui): tab focus state 2026-01-24 16:57:38 -06:00
adamelmore d97cd56867 fix(ui): popover exit ux 2026-01-24 16:57:38 -06:00
David Hill 43906f56c8 fix(app): remove space between ellipsis and truncated text in comment card tooltip 2026-01-24 22:42:52 +00:00
David Hill 241087d1dc fix(app): update status popover empty state text color and centering 2026-01-24 22:35:24 +00:00
David Hill fba77a364c fix(ui): prevent tooltip fade when forceOpen is true 2026-01-24 22:29:38 +00:00
David Hill 3d956c5f7e fix(ui): show 'Copied' tooltip instantly when copy button clicked 2026-01-24 22:25:20 +00:00
David Hill 4a4c1b31a7 fix(ui): update copy button tooltip gutter and label to 'Copy link' 2026-01-24 22:22:01 +00:00
David Hill 30111d2b16 fix(ui): prevent focus on share popover text field 2026-01-24 22:17:59 +00:00
David Hill b695216063 fix(ui): align list search input width with list items 2026-01-24 22:11:31 +00:00
David Hill c2ec608212 feat(ui): add link icon and use it for copy-to-clipboard buttons
Replace copy icon with new link icon in share URL copy button
and TextField copyable button for better visual indication.
2026-01-24 22:02:09 +00:00
David Hill c1af7ddc6b fix(app): adjust share popover position 64px to the left 2026-01-24 22:02:09 +00:00
David Hill cf7c6417f8 fix(app): update share popover gutter to 6px and radius to match status dropdown 2026-01-24 22:02:09 +00:00
David Hill 937474aff0 fix(app): add 8px spacing between share button and icon buttons in titlebar 2026-01-24 22:02:09 +00:00
David Hill a878b8d7ac refactor(app): replace Popover with DropdownMenu for server options 2026-01-24 22:02:09 +00:00
David Hill b824fc5516 fix(app): update options icon button styling - active state and hover 2026-01-24 22:02:09 +00:00
David Hill c56f6127c7 fix(app): change server item actions div padding from px-4 to pl-4 2026-01-24 22:02:09 +00:00
David Hill 8845f2b926 feat(ui): add onFilter callback to List, discard add server row when searching 2026-01-24 22:02:09 +00:00
David Hill df4d839577 fix(app): position status circle inside input wrapper and fix dialog padding 2026-01-24 22:02:09 +00:00
David Hill 8fe42cd5dc fix(app): remove hover background color from server list items 2026-01-24 22:02:09 +00:00
David Hill a169c2987b fix(app): allow add server row to grow for error message 2026-01-24 22:02:08 +00:00
David Hill a5c08bc4f8 fix(app): update add server button and row styling 2026-01-24 22:02:08 +00:00
David Hill 02aea77e92 feat(app): update manage servers dialog styling and behavior 2026-01-24 22:02:08 +00:00
David Hill a98add29d1 feat(app): add truncation tooltip to server items in status popover 2026-01-24 22:02:08 +00:00
David Hill d01df32e36 fix(app): update server and MCP item styles in status popover 2026-01-24 22:02:08 +00:00
David Hill 2c620e1742 fix(app): update status popover styling and positioning 2026-01-24 22:02:08 +00:00
David Hill 262084d7e6 fix(app): use rounded-sm for explicit 4px border radius 2026-01-24 22:02:08 +00:00
David Hill b089358503 fix(app): update titlebar spacing and status popover styling 2026-01-24 22:02:08 +00:00
David Hill 02456376ce fix(app): enable submit button when comment cards are present 2026-01-24 22:02:08 +00:00
GitHub Action faf2609bc5 chore: generate 2026-01-24 21:53:51 +00:00
Liyang Zhu aeeb05e4a0 feat(app): back button in subagent sessions (#10439) 2026-01-24 15:53:15 -06:00
adamelmore 847a7ca009 fix(app): don't show scroll to bottom if no scroll 2026-01-24 15:01:17 -06:00
adamelmore dc1ff0e63e fix(app): model select not closing on escape 2026-01-24 15:01:05 -06:00
adamelmore 7ba25c6afb fix(app): model selector ux 2026-01-24 15:01:05 -06:00
adamelmore b951187a6e fix(app): no select on new session 2026-01-24 14:58:16 -06:00
Maharshi Patel 8f99e9a606 fix(opentui): question selection click when terminal unfocused (#9731) 2026-01-24 15:12:16 -05:00
adamelmore 27b45d070d fix(app): scrolling for unpaid model selector 2026-01-24 13:38:14 -06:00
Dax Raad 07d7dc083c ci: remove unused environment variables from test workflow
Removes MODELS_DEV_API_JSON and OPENCODE_DISABLE_MODELS_FETCH environment variables that were redundant in the test workflow, simplifying the configuration.
2026-01-24 14:26:59 -05:00
Dax Raad eaa622e852 fix adam 2026-01-24 14:25:01 -05:00
Dax Raad ff9c186485 tests 2026-01-24 14:16:46 -05:00
adamelmore 41f2653a30 fix(app): prompt submission failing on first message 2026-01-24 13:15:34 -06:00
Dax Raad 0d9ca0ea31 sync 2026-01-24 14:14:17 -05:00
Dax Raad 68bd16df69 core: fix models snapshot loading to prevent caching issues 2026-01-24 14:06:07 -05:00
GitHub Action b3901ac38b chore: generate 2026-01-24 18:59:51 +00:00
David Hill 48236ee0ef feat(ui): add critical shadow for comment input validation, set editor popover radius to 14px 2026-01-24 18:59:07 +00:00
David Hill e2bffc29f2 fix(ui): change read-only comment popover border-radius to 8px 2026-01-24 18:59:07 +00:00
David Hill fda897eac4 fix(app): improve comment popover - remove disabled state, add error styling, fix click-outside detection 2026-01-24 18:59:07 +00:00
David Hill e5d2d984b6 feat(app): change prompt placeholder based on comment count 2026-01-24 18:59:07 +00:00
David Hill bfb0885371 fix(util): change filename truncation to end truncation, add truncateMiddle utility 2026-01-24 18:59:07 +00:00
David Hill 0d41f1fc24 fix(ui): remove unnecessary !important from diff selection styles 2026-01-24 18:59:07 +00:00
David Hill 363ff153a4 fix(ui): fix selected line number color in diff view for light/dark mode 2026-01-24 18:59:07 +00:00
David Hill f4bcf0062a fix(app): adjust prompt container padding to 16px bottom and horizontal 2026-01-24 18:59:07 +00:00
David Hill 9759afad83 fix(app): adjust prompt input positioning - 12px from bottom/right, remove session panel bottom padding 2026-01-24 18:59:07 +00:00
David Hill ac204ed89d fix(ui): add escape/click-away to close read-only comment popovers, 10px radius, remove 'Click to view context' text 2026-01-24 18:59:06 +00:00
adamelmore 1080f37f9c fix(app): don't use findLast 2026-01-24 12:41:50 -06:00
adamelmore d90b4c9ebd fix(app): line selection ux 2026-01-24 12:41:50 -06:00
adamelmore 42b802b688 fix(app): line selection ux fixes 2026-01-24 12:41:50 -06:00
Dax Raad fa1a54ba3d fix nix 2026-01-24 13:24:40 -05:00
GitHub Action 8f0d08fae0 chore: generate 2026-01-24 18:17:29 +00:00
Joseph Campuzano 15801a01ba fix: add state to pause existing audio for demo menus, add support fo… (#10428) 2026-01-24 12:16:53 -06:00
Dax Raad 32e6bcae3b core: fix unicode filename handling in snapshot diff by disabling quote escaping
This ensures unicode and special characters in filenames are displayed correctly when generating diff patches, allowing proper file detection and revert operations
2026-01-24 13:07:07 -05:00
zerone0x 087d7da14d fix(provider): deep merge providerOptions in applyCaching (#10323)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-24 13:07:04 -05:00
GitHub Action 442a735883 chore: generate 2026-01-24 18:04:12 +00:00
OpeOginni 67ea21b55a feat(web): implement new server management for web and desktop (#8513) 2026-01-24 12:03:36 -06:00
Britt f4cf3f4976 fix(web): construct apply_patch metadata before requesting permission (#10422) 2026-01-24 12:00:21 -06:00
Dax Raad e3c1861a3e get rid of models.dev macro 2026-01-24 12:27:13 -05:00
GitHub Action d9eebba90e chore: generate 2026-01-24 17:27:03 +00:00
Aiden Cline c5ef6452b3 ignore: update commit.md 2026-01-24 12:26:21 -05:00
Sebastian Herrlinger ad27427b48 use min/maxHeight for question textarea 2026-01-24 12:14:12 -05:00
Aiden Cline 88bcd04659 tweak: tell the model what model it is in environment section of prompt 2026-01-24 12:10:15 -05:00
justfortheloveof 077d17d433 fix: permission prompt should ignore keyboard events while dialog stack len > 0 (#10338) 2026-01-24 12:01:46 -05:00
Filip 6511243152 feat(docs): add a desktop troubleshooting guide (#10397) 2026-01-24 12:00:51 -05:00
GitHub Action ea8d727e28 chore: generate 2026-01-24 16:55:54 +00:00
Frank b590bda5ed zen: show reload error 2026-01-24 11:48:19 -05:00
Frank d8bbb6df60 zen: disable reload when reload fails 2026-01-24 11:48:19 -05:00
adamelmore 7c2e59de68 fix(app): new workspace expanded and at the top 2026-01-24 09:12:32 -06:00
adamelmore fa510161f6 fix(app): missing translations 2026-01-24 09:12:12 -06:00
adamelmore 6abe86806f fix(app): better error screen when connecting to sidecar 2026-01-24 09:10:02 -06:00
adamelmore 6d8e994383 fix(app): line selection fixes 2026-01-24 09:09:27 -06:00
GitHub Action ae77ef3370 chore: generate 2026-01-24 14:47:36 +00:00
Ariane Emory 68e504bdc2 fix(tui): Use selectedForeground for question prompt tab text visibility (resolves #10334) (#10337) 2026-01-24 09:46:59 -05:00
Rahul A Mistry 91287dd7bc fix(app): tooltip text in light mode to use inverted neutral scale (#9786) 2026-01-24 07:17:20 -06:00
adamelmore 09f45320b7 chore: cleanup 2026-01-24 07:00:41 -06:00
adamelmore 962ab3bc8c fix(app): reactive loops 2026-01-24 07:00:41 -06:00
adamelmore da8f3e92a7 perf(app): better session stream rendering 2026-01-24 07:00:41 -06:00
GitHub Action 04b511e1fe chore: generate 2026-01-24 12:50:47 +00:00
adamelmore 456469d541 fix(app): tool details indentation 2026-01-24 06:50:01 -06:00
adamelmore d96877f173 fix(app): sticky header top 2026-01-24 06:50:01 -06:00
Ariane Emory 98b66ff933 feat(desktop): add Iosevka as a font choice (resolves #10103) (#10347) 2026-01-24 06:28:58 -06:00
Devin Griffin 5f7111fe93 fix(app): Always close hovercard when view-sessions clicked (#10326) 2026-01-24 06:27:32 -06:00
Devin Griffin d5f78a7278 fix(app): Fix plan mode btn keyboard a11y issues (#10330) 2026-01-24 06:27:09 -06:00
GitHub Action 1533c50ac3 ignore: update download stats 2026-01-24 2026-01-24 12:04:28 +00:00
David Hill 0cc206a1a5 update: border radius on popover card 2026-01-24 06:18:56 +00:00
David Hill d4443d79c7 update: border variable 2026-01-24 06:18:55 +00:00
David Hill c9215e8dc3 fix(ui): style review tab comment button to match file tab - blue background, white comment icon 2026-01-24 06:18:55 +00:00
David Hill 58788192f4 fix(ui): close comment input popover on Escape key or click away 2026-01-24 06:18:55 +00:00
David Hill 40ab6ac862 fix(ui): use shadow-lg-border-base on read-only comment popovers and align label spacing 2026-01-24 06:18:55 +00:00
David Hill 31f80a45af fix(ui): remove border from comment input popover 2026-01-24 06:18:55 +00:00
David Hill 0a9f51f87f fix(ui): position read-only comment popover below icon with 4px gutter 2026-01-24 06:18:55 +00:00
David Hill af6bd9d3b1 fix(ui): style comment popovers - 14px radius, move label below, use text-weak for label, text-strong 14px for comment 2026-01-24 06:18:55 +00:00
David Hill c66da17364 fix(ui): move filename and line count below comment text in popovers 2026-01-24 06:18:55 +00:00
David Hill b280207481 fix(app): add tooltip with path, 6px spacing before close icon, and reduce filename truncation to 14 chars 2026-01-24 06:18:55 +00:00
David Hill 75cccc305a feat(app): add middle truncation for filename in comment card 2026-01-24 06:18:55 +00:00
David Hill 18ea09868a fix(app): truncate filename from start to show end of path 2026-01-24 06:18:55 +00:00
David Hill 1df697dec7 fix(app): remove gap between filename and comment in comment card 2026-01-24 06:18:55 +00:00
David Hill 1476c4ca49 fix(app): add shadow-xs-border with hover state to comment card 2026-01-24 06:18:55 +00:00
David Hill 3b3ab29d8c fix(app): comment card styling - 48px height, 2px gap, truncate filename while keeping line count visible 2026-01-24 06:18:55 +00:00
David Hill 258d207fd6 fix(app): increase comment font size to 12px 2026-01-24 06:18:55 +00:00
David Hill 4b64bff11b fix(app): add 8px gap before close icon and truncate long filenames 2026-01-24 06:18:55 +00:00
David Hill c70e8b5880 fix(app): keep close icon in top right of comment card 2026-01-24 06:18:55 +00:00
David Hill 42a1a1202c fix(app): add transition-all to comment card hover states 2026-01-24 06:18:55 +00:00
David Hill d3490cfd29 feat(ui): add close-small icon and use it for comment card dismiss button 2026-01-24 06:18:55 +00:00
David Hill 5384040051 fix(app): truncate comment text and set card max-width to 200px 2026-01-24 06:18:55 +00:00
David Hill 1bf4caa0c1 fix(app): indent comment text to align with filename in context card 2026-01-24 06:18:55 +00:00
David Hill 35a3c98221 fix(app): style submitted comment icons to match comment popup style 2026-01-24 06:18:55 +00:00
David Hill 56ece04dd5 fix(app): update prompt input styling - 14px border radius, card hover states, and 8px padding 2026-01-24 06:18:55 +00:00
David Hill 328bd3fb02 fix(app): update context cards styling with 8px padding/gap and 6px border radius 2026-01-24 06:18:55 +00:00
David Hill 2daa3652bb fix(ui): add button-primary-base variable and use primary variant for Comment button 2026-01-24 06:18:54 +00:00
David Hill ae84e9909a fix(app): improve comment popup styling and add new comment icon 2026-01-24 06:18:54 +00:00
Fynn b978ca11da fix: retry webfetch with simple UA on 403 (#10328) 2026-01-24 00:14:32 -05:00
Github Action e452b3cae0 chore: update nix node_modules hashes 2026-01-24 05:13:52 +00:00
GitHub Action e2d8310b76 chore: generate 2026-01-24 05:12:45 +00:00
Daniel Olowoniyi 1d09343f17 fix: allow gpt-5.1-codex model in codex auth pluginFixes (#10181) 2026-01-24 00:11:54 -05:00
Vladimir Glafirov 6633f0e6fa fix: bump gitlab-ai-provider version (#10255) 2026-01-24 00:11:18 -05:00
Shantur Rathore 4173adf5e2 feat(tasks): Add model info as part of metadata (#10307) 2026-01-24 00:10:40 -05:00
Arthur cf7e10c4e8 fix: add xhigh reasoning effort for GitHub Copilot GPT-5 models (#10092)
Co-authored-by: Arthur Freitas Ramos <arthur@MacBook-Air-de-Arthur.local>
2026-01-24 00:09:47 -05:00
Frank af5e405391 zen: remove grok code model 2026-01-23 23:25:34 -05:00
Alex Yaroshuk 8a216a6ad5 fix(app): normalize path separators for session diff filtering on Windows (#10291) 2026-01-23 16:17:47 -06:00
Ariane Emory 225b72ca36 feat: always center selected item in selection dialogs (resolves #10209) (#10207) 2026-01-23 11:59:39 -06:00
Rahul A Mistry 8105f186dc fix(app): center checkbox indicator in provider selection (#10267) 2026-01-23 10:23:24 -06:00
GitHub Action 4f1bdf1c59 chore: generate 2026-01-23 16:22:07 +00:00
Frank 472695caca zen: fix balance not shown 2026-01-23 11:21:08 -05:00
GitHub Action 469fd43c71 chore: generate 2026-01-23 15:59:00 +00:00
Frank 24d942349f zen: use balance after rate limited 2026-01-23 10:58:00 -05:00
Edin 65c236c071 feat(app): auto-open oauth links for codex and copilot (#10258) 2026-01-23 09:35:44 -06:00
GitHub Action d6c5ddd6dc ignore: update download stats 2026-01-23 2026-01-23 12:05:37 +00:00
Adam e5fe50f7da fix(app): close delete workspace dialog immediately 2026-01-23 05:41:51 -06:00
Adam b6beda1569 fix: type error 2026-01-23 05:32:37 -06:00
GitHub Action f34b509fe7 chore: generate 2026-01-23 11:19:35 +00:00
Adam 2a2d800ac4 fix: type error 2026-01-23 05:18:57 -06:00
Adam 4afb46f571 perf(app): don't remount directory layout 2026-01-23 05:18:57 -06:00
Adam c4d223eb99 perf(app): faster workspace creation 2026-01-23 05:18:42 -06:00
GitHub Action 3fbda54045 chore: generate 2026-01-23 11:10:22 +00:00
Shantur Rathore 41ede06b20 docs(ecosystem): Add CodeNomad entry to ecosystem documentation (#10222) 2026-01-23 05:09:38 -06:00
Adam 82ec84982e Reapply "wip(app): line selection"
This reverts commit df7b6792cd.
2026-01-23 05:01:10 -06:00
Adam df7b6792cd Revert "wip(app): line selection"
This reverts commit 1780bab1ce.
2026-01-23 04:58:41 -06:00
Devin Griffin c72d9a473c fix(app): View all sessions flakiness (#10149) 2026-01-23 04:57:10 -06:00
GitHub Action d3688b150a chore: generate 2026-01-23 10:55:37 +00:00
Rahul A Mistry e376e1de16 fix(app): enable dialog dismiss on model selector (dialog.tsx) (#10203) 2026-01-23 04:55:00 -06:00
176 changed files with 4684 additions and 1765 deletions
-8
View File
@@ -53,7 +53,6 @@ jobs:
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV"
else
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
@@ -61,7 +60,6 @@ jobs:
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV"
fi
- name: Seed opencode data
@@ -69,8 +67,6 @@ jobs:
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
@@ -90,8 +86,6 @@ jobs:
working-directory: packages/opencode
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
env:
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
@@ -117,8 +111,6 @@ jobs:
run: ${{ matrix.settings.command }}
env:
CI: true
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
+13 -1
View File
@@ -1,6 +1,6 @@
---
description: git commit and push
model: opencode/glm-4.6
model: opencode/glm-4.7
subtask: true
---
@@ -26,3 +26,15 @@ about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF
!`git diff`
## GIT DIFF --cached
!`git diff --cached`
## GIT STATUS --short
!`git status --short`
-1
View File
@@ -4,7 +4,6 @@
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"instructions": ["STYLE_GUIDE.md"],
"provider": {
"opencode": {
"options": {},
+72 -1
View File
@@ -1,4 +1,75 @@
- To test opencode in `packages/opencode`, run `bun dev`.
- 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`.
## Style Guide
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
# Avoid let statements
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or using an `iife` to avoid else statements.
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
+1 -1
View File
@@ -148,7 +148,7 @@ This runs `bun run --cwd packages/desktop build` automatically via Tauris `be
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./STYLE_GUIDE.md)
Please try to follow the [style guide](./AGENTS.md)
### Setting up a Debugger
+3
View File
@@ -208,3 +208,6 @@
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
| 2026-01-21 | 5,444,842 (+315,843) | 1,962,531 (+58,866) | 7,407,373 (+374,709) |
| 2026-01-22 | 5,766,340 (+321,498) | 2,029,487 (+66,956) | 7,795,827 (+388,454) |
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
-71
View File
@@ -1,71 +0,0 @@
## Style Guide
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
# Avoid let statements
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or using an `iife` to avoid else statements.
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
+25 -20
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -211,7 +211,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -240,7 +240,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -256,7 +256,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.34",
"version": "1.1.36",
"bin": {
"opencode": "./bin/opencode",
},
@@ -284,7 +284,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.2.0",
"@gitlab/gitlab-ai-provider": "3.3.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -360,7 +360,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -380,9 +380,9 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.34",
"version": "1.1.36",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -391,7 +391,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -404,7 +404,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -423,6 +423,7 @@
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
@@ -445,7 +446,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"zod": "catalog:",
},
@@ -456,7 +457,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -922,17 +923,19 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.2.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-sqP34jDSWWEHygmYbM2rzIcRjhA+1FHVHj8mxUvVz1s7o2Cgb1NnOaUXU7eWTI0AGhO+tPYHDTqI/mRC4cdjlQ=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.2", "", { "dependencies": { "ansi-colors": "4.1.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-88cqrrB2cLXN8nMOHidQTcVOnZsJ5kebEbBefjMCifaUCwTA30ouSSWvTZqrOX4O104zjJyu7M8Gcv/NNYQuaA=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="],
"@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="],
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.4", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.2", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-9l++kjcb0ui4JqPlueZ6OZ9zKn6eK/8//Z2jHcIXb5MRwDRgubOOSpTU5llEv3uvWfT10VzcMp99dySWq0AASw=="],
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.10", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.5", "@hey-api/json-schema-ref-parser": "1.2.2", "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-o0wlFxuLt1bcyIV/ZH8DQ1wrgODTnUYj/VfCHOOYgXUQlLp9Dm2PjihOz+WYrZLowhqUhSKeJRArOGzvLuOTsg=="],
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
@@ -3102,6 +3105,8 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+2
View File
@@ -77,6 +77,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"invoice.payment_failed",
"invoice.payment_action_required",
"customer.created",
"customer.deleted",
"customer.updated",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-wSkJcUnS0ODOYkbkjRnxnjfWYKQOVXwkDNB8qrikuLA=",
"aarch64-linux": "sha256-4BlpH/oIXRJEjkQydXDv1oi1Yx7li3k1dKHUy2/Gb10=",
"aarch64-darwin": "sha256-awW0ooZo/QfB2xmRdZ9XLNzQ9sP/mbN+rTg215id6nc=",
"x86_64-darwin": "sha256-CHrE2z+LqY2WXTQeGWG5LNMF1AY4UGSwViJAy4IwIVw="
"x86_64-linux": "sha256-olTZ+tKugAY3LxizsJMlbK3TW78HZUoM03PigvQLP4A=",
"aarch64-linux": "sha256-xdKDeqMEnYM2+vGySfb8pbcYyo/xMmgxG/ZhPCKaZEg=",
"aarch64-darwin": "sha256-fihCTrHIiUG+py4vuqdr+YshqSKm2/B5onY50b97sPM=",
"x86_64-darwin": "sha256-inlQQPNAOdkmKK6HQAMI2bG/ZFlfwmUQu9a6vm6Q0jQ="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.34",
"version": "1.1.36",
"description": "",
"type": "module",
"exports": {
-3
View File
@@ -45,7 +45,6 @@ async function waitForHealth(url: string) {
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
const extraArgs = (() => {
const args = process.argv.slice(2)
@@ -59,8 +58,6 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const serverEnv = {
...process.env,
MODELS_DEV_API_JSON: modelsJson,
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_DISABLE_SHARE: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
@@ -187,7 +187,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
@@ -373,6 +373,9 @@ export function DialogConnectProvider(props: { provider: string }) {
})
onMount(async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
@@ -32,8 +32,8 @@ export function DialogSelectFile() {
const dialog = useDialog()
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = [
@@ -35,9 +35,10 @@ export const DialogSelectModelUnpaid: Component = () => {
return (
<Dialog title={language.t("dialog.model.select.title")}>
<div class="flex flex-col gap-3 px-2.5">
<div class="flex flex-col gap-3 px-2.5 flex-1 min-h-0">
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
ref={(ref) => (listRef = ref)}
items={local.model.list}
current={local.model.current()}
@@ -1,5 +1,6 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, createSignal, JSX, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
@@ -92,26 +93,118 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
triggerAs?: T
triggerProps?: ComponentProps<T>
}) {
const [open, setOpen] = createSignal(false)
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({
open: false,
dismiss: null,
trigger: undefined,
content: undefined,
})
const dialog = useDialog()
const handleManage = () => {
setOpen(false)
setStore("open", false)
dialog.show(() => <DialogManageModels />)
}
const language = useLanguage()
createEffect(() => {
if (!store.open) return
const inside = (node: Node | null | undefined) => {
if (!node) return false
const el = store.content
if (el && el.contains(node)) return true
const anchor = store.trigger
if (anchor && anchor.contains(node)) return true
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
const onFocusIn = (event: FocusEvent) => {
if (!store.content) return
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("pointerdown", onPointerDown, true)
window.addEventListener("focusin", onFocusIn, true)
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("pointerdown", onPointerDown, true)
window.removeEventListener("focusin", onFocusIn, true)
})
})
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...(props.triggerProps as any)}>
<Kobalte
open={store.open}
onOpenChange={(next) => {
if (next) setStore("dismiss", null)
setStore("open", next)
}}
modal={false}
placement="top-start"
gutter={8}
>
<Kobalte.Trigger
ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Content
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onCloseAutoFocus={(event) => {
if (store.dismiss === "outside") event.preventDefault()
setStore("dismiss", null)
}}
>
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList
provider={props.provider}
onSelect={() => setOpen(false)}
onSelect={() => setStore("open", false)}
class="p-1"
action={
<IconButton
@@ -56,6 +56,12 @@ export const DialogSelectProvider: Component = () => {
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
<Show when={i.id === "openai"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
</Show>
<Show when={i.id.startsWith("github-copilot")}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
</Show>
</div>
)}
</List>
@@ -1,23 +1,48 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useGlobalSDK } from "@/context/global-sdk"
type ServerStatus = { healthy: boolean; version?: string }
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
interface AddRowProps {
value: string
placeholder: string
adding: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
interface EditRowProps {
value: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onBlur: () => void
}
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const sdk = createOpencodeClient({
baseUrl: url,
fetch,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
@@ -26,21 +51,158 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis
.catch(() => ({ healthy: false }))
}
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<div
classList={{
"size-1.5 rounded-full absolute left-3 z-10 pointer-events-none": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
style={{ top: "50%", transform: "translateY(-50%)" }}
ref={(el) => {
// Position relative to input-wrapper
requestAnimationFrame(() => {
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
if (wrapper instanceof HTMLElement) {
wrapper.style.position = "relative"
wrapper.appendChild(el)
}
})
}}
/>
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.adding}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
class="pl-7"
/>
</div>
</div>
)
}
function EditRow(props: EditRowProps) {
return (
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status === true,
"bg-icon-critical-base": props.status === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<div class="flex-1 min-w-0">
<TextField
type="text"
hideLabel
placeholder={props.placeholder}
value={props.value}
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
onBlur={props.onBlur}
/>
</div>
</div>
)
}
export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [store, setStore] = createStore({
url: "",
adding: false,
error: "",
status: {} as Record<string, ServerStatus | undefined>,
addServer: {
url: "",
adding: false,
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
},
})
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
},
{ initialValue: null },
)
const isDesktop = platform.platform === "desktop"
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, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkHealth(normalized, platform)
setStatus(result.healthy)
}
const resetAdd = () => {
setStore("addServer", {
url: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
error: "",
status: undefined,
busy: false,
})
}
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
server.add(next)
if (nextActive) server.setActive(nextActive)
server.remove(original)
}
const items = createMemo(() => {
const current = server.url
const list = server.list
@@ -74,7 +236,7 @@ export function DialogSelectServer() {
const results: Record<string, ServerStatus> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkHealth(url, platform.fetch)
results[url] = await checkHealth(url, platform)
}),
)
setStore("status", reconcile(results))
@@ -87,7 +249,7 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval))
})
function select(value: string, persist?: boolean) {
async function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
dialog.close()
if (persist) {
@@ -99,24 +261,101 @@ export function DialogSelectServer() {
navigate("/")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const value = normalizeServerUrl(store.url)
if (!value) return
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, (next) => setStore("addServer", { status: next }))
}
setStore("adding", true)
setStore("error", "")
const scrollListToBottom = () => {
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
})
}
const result = await checkHealth(value, platform.fetch)
setStore("adding", false)
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, (next) => setStore("editServer", { status: next }))
}
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("url", "")
select(value, true)
setStore("addServer", { adding: true, error: "" })
const result = await checkHealth(normalized, platform)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(normalized, true)
}
async function handleEdit(original: string, value: string) {
if (store.editServer.busy) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
if (normalized === original) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkHealth(normalized, platform)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
replaceServer(original, normalized)
resetEdit()
}
const handleAddKey = (event: KeyboardEvent) => {
event.stopPropagation()
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleAdd(store.addServer.url)
}
const blurAdd = () => {
if (!store.addServer.url.trim()) {
resetAdd()
return
}
handleAdd(store.addServer.url)
}
const handleEditKey = (event: KeyboardEvent, original: string) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
resetEdit()
return
}
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: string) {
@@ -124,125 +363,203 @@ export function DialogSelectServer() {
}
return (
<Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
<div class="flex flex-col gap-4 pb-4">
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
current={current()}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
>
{(i) => (
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
{(i) => {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
createEffect(() => {
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(i)
const version = store.status[i]?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<div
class="flex items-center gap-3 px-4 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span ref={nameRef} class="truncate">
{serverDisplayName(i)}
</span>
<Show when={store.status[i]?.version}>
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
{store.status[i]?.version}
</span>
</Show>
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
</div>
</Tooltip>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<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">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={isDesktop && defaultUrl() !== i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(i)
defaultUrlActions.mutate(i)
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isDesktop && defaultUrl() === i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.mutate(null)
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
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>
</Show>
</div>
<Show when={current() !== i && server.list.includes(i)}>
<IconButton
icon="circle-x"
variant="ghost"
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
aria-label={language.t("dialog.server.action.remove")}
onClick={(e) => {
e.stopPropagation()
handleRemove(i)
}}
/>
</Show>
</div>
)}
)
}}
</List>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.add.title")}</h3>
</div>
<form onSubmit={handleSubmit}>
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0 h-auto">
<TextField
type="text"
label={language.t("dialog.server.add.url")}
hideLabel
placeholder={language.t("dialog.server.add.placeholder")}
value={store.url}
onChange={(v) => {
setStore("url", v)
setStore("error", "")
}}
validationState={store.error ? "invalid" : "valid"}
error={store.error}
/>
</div>
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
{store.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
</div>
</form>
<div class="px-5 pb-5">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={() => {
setStore("addServer", { showForm: true, url: "", error: "" })
scrollListToBottom()
}}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
</Button>
</div>
<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
<p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={
<span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
{language.t("dialog.server.default.set")}
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
{language.t("dialog.server.default.clear")}
</Button>
</Show>
</div>
</div>
</Show>
</div>
</Dialog>
)
+280 -72
View File
@@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
@@ -48,6 +48,7 @@ import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
@@ -61,6 +62,13 @@ import { base64Encode } from "@opencode-ai/util/encode"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
@@ -115,6 +123,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout()
const comments = useComments()
const params = useParams()
@@ -128,6 +137,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const scrollCursorIntoView = () => {
const container = scrollRef
const selection = window.getSelection()
@@ -159,8 +170,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const commentInReview = (path: string) => {
const sessionID = params.id
if (!sessionID) return false
const diffs = sync.data.session_diff[sessionID]
if (!diffs) return false
return diffs.some((diff) => diff.file === path)
}
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
view().reviewPanel.open()
if (item.commentOrigin === "review") {
tabs().open("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (item.commentOrigin !== "file" && commentInReview(item.path)) {
tabs().open("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
const tab = files.tab(item.path)
tabs().open(tab)
files.load(item.path)
requestAnimationFrame(() => comments.setFocus(focus))
}
const recent = createMemo(() => {
const all = tabs().all()
@@ -611,6 +656,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
@@ -725,6 +789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
if (prompt.dirty()) {
mirror.input = true
prompt.set(DEFAULT_PROMPT, 0)
}
queueScroll()
@@ -755,6 +820,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition)
queueScroll()
}
@@ -846,12 +912,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null)
}
const abort = () =>
sdk.client.session
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
return Promise.resolve()
}
return sdk.client.session
.abort({
sessionID: params.id!,
sessionID,
})
.catch(() => {})
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt
@@ -1111,6 +1187,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
return
}
WorktreeState.pending(createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -1369,10 +1446,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model,
}
const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
const addOptimisticMessage = () => {
setSyncStore(
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session.id]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
}
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
return
}
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session.id]
if (!messages) {
@@ -1390,7 +1484,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const removeOptimisticMessage = () => {
setSyncStore(
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[messageID]
}),
)
return
}
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session.id]
if (messages) {
@@ -1409,20 +1517,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
addOptimisticMessage()
client.session
.prompt({
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
prompt.context.add({
@@ -1431,11 +1539,79 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
restoreInput()
}
pending.set(session.id, { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
const timeoutMs = 5 * 60 * 1000
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
setTimeout(() => {
resolve({ status: "failed", message: "Workspace is still preparing" })
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout])
pending.delete(session.id)
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
}
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
}
void send().catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
for (const item of commentItems) {
prompt.context.add({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
restoreInput()
})
}
return (
@@ -1545,7 +1721,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.dragging,
[props.class ?? ""]: !!props.class,
}}
@@ -1559,54 +1735,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
<Show when={prompt.context.items().length > 0}>
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={prompt.context.items()}>
{(item) => {
const active = () => {
const a = comments.active()
return !!item.commentID && item.commentID === a?.id && item.path === a?.file
}
return (
<div
classList={{
"shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
"cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
}}
onClick={() => {
if (!item.commentID) return
comments.setFocus({ file: item.path, id: item.commentID })
view().reviewPanel.open()
tabs().open("review")
}}
<Tooltip
value={
<span class="flex max-w-[300px]">
<span
class="text-text-invert-base truncate min-w-0"
style={{ direction: "rtl", "text-align": "left", "unicode-bidi": "plaintext" }}
>
{getDirectory(item.path)}
</span>
<span class="shrink-0">{getFilename(item.path)}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
active(),
"bg-background-stronger": !active(),
}}
onClick={() => {
openComment(item)
}}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div
class="flex items-center text-11-regular min-w-0"
style={{ "font-weight": "var(--font-weight-medium)" }}
>
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
aria-label={language.t("prompt.context.removeFile")}
/>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
aria-label={language.t("prompt.context.removeFile")}
/>
<Show when={item.comment}>
{(comment) => (
<div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
)}
</Show>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
</Show>
</div>
</Tooltip>
)
}}
</For>
@@ -1662,7 +1862,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-label={
store.mode === "shell"
? language.t("prompt.placeholder.shell")
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
: commentCount() > 1
? "Summarize comments…"
: commentCount() === 1
? "Summarize comment…"
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
}
contenteditable="true"
onInput={handleInput}
@@ -1672,17 +1876,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? language.t("prompt.placeholder.shell")
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
: commentCount() > 1
? "Summarize comments…"
: commentCount() === 1
? "Summarize comment…"
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
</div>
</Show>
</div>
@@ -1789,7 +1997,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-3 absolute right-2 bottom-2">
<div class="flex items-center gap-3 absolute right-3 bottom-3">
<input
ref={fileInputRef}
type="file"
@@ -4,6 +4,7 @@ import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
import { findLast } from "@opencode-ai/util/array"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
@@ -21,22 +22,26 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
)
const cost = createMemo(() => {
const locale = language.locale()
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(total)
return usd().format(total)
})
const context = createMemo(() => {
const locale = language.locale()
const last = messages().findLast((x) => {
const last = findLast(messages(), (x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
@@ -84,9 +89,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
</div>
<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">{language.t("context.usage.clickToView")}</div>
</Show>
</div>
)
@@ -1,42 +0,0 @@
import { createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export function SessionLspIndicator() {
const sync = useSync()
const language = useLanguage()
const lspStats = createMemo(() => {
const lsp = sync.data.lsp ?? []
const connected = lsp.filter((s) => s.status === "connected").length
const hasError = lsp.some((s) => s.status === "error")
const total = lsp.length
return { connected, hasError, total }
})
const tooltipContent = createMemo(() => {
const lsp = sync.data.lsp ?? []
if (lsp.length === 0) return language.t("lsp.tooltip.none")
return lsp.map((s) => s.name).join(", ")
})
return (
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": lspStats().hasError,
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
}}
/>
<span class="text-12-regular text-text-weak">
{language.t("lsp.label.connected", { count: lspStats().connected })}
</span>
</div>
</Tooltip>
</Show>
)
}
@@ -1,34 +0,0 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
export function SessionMcpIndicator() {
const sync = useSync()
const dialog = useDialog()
const mcpStats = createMemo(() => {
const mcp = sync.data.mcp ?? {}
const entries = Object.entries(mcp)
const enabled = entries.filter(([, status]) => status.status === "connected").length
const failed = entries.some(([, status]) => status.status === "failed")
const total = entries.length
return { enabled, failed, total }
})
return (
<Show when={mcpStats().total > 0}>
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": mcpStats().failed,
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
}}
/>
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
</Button>
</Show>
)
}
@@ -5,6 +5,7 @@ import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -25,8 +26,16 @@ export function SessionContextTab(props: SessionContextTabProps) {
const sync = useSync()
const language = useLanguage()
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
)
const ctx = createMemo(() => {
const last = props.messages().findLast((x) => {
const last = findLast(props.messages(), (x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
@@ -61,12 +70,8 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const cost = createMemo(() => {
const locale = language.locale()
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(total)
return usd().format(total)
})
const counts = createMemo(() => {
@@ -81,7 +86,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -5,8 +5,6 @@ import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -20,14 +18,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
import { StatusPopover } from "../status-popover"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
// const server = useServer()
// const dialog = useDialog()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
@@ -50,7 +47,7 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const showReview = createMemo(() => !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
const view = createMemo(() => layout.view(sessionKey))
const [state, setState] = createStore({
share: false,
@@ -154,65 +151,104 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
{/* <div class="hidden md:flex items-center gap-1"> */}
{/* <Button */}
{/* size="small" */}
{/* variant="ghost" */}
{/* onClick={() => { */}
{/* dialog.show(() => <DialogSelectServer />) */}
{/* }} */}
{/* > */}
{/* <div */}
{/* classList={{ */}
{/* "size-1.5 rounded-full": true, */}
{/* "bg-icon-success-base": server.healthy() === true, */}
{/* "bg-icon-critical-base": server.healthy() === false, */}
{/* "bg-border-weak-base": server.healthy() === undefined, */}
{/* }} */}
{/* /> */}
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
{/* </Button> */}
{/* <SessionLspIndicator /> */}
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<div class="hidden md:block shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
<StatusPopover />
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
gutter={6}
placement="bottom-end"
shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
triggerAs={Button}
triggerProps={{
variant: "secondary",
class: "rounded-sm w-[60px] h-[24px]",
classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
tabIndex={showReview() ? 0 : -1}
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2">
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<IconButton
icon={state.copied ? "check" : "link"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
aria-label={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
/>
</Tooltip>
</Show>
</div>
</Show>
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
class="hidden md:block shrink-0"
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
@@ -244,96 +280,37 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
triggerAs={Button}
triggerProps={{
variant: "secondary",
classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
<div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
tabIndex={showReview() ? 0 : -1}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip
value={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
aria-label={
state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
</Tooltip>
</Show>
</div>
</Show>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</Portal>
)}
@@ -4,7 +4,6 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
@@ -60,18 +59,7 @@ export function NewSessionView(props: NewSessionViewProps) {
</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<Select
options={options()}
current={current()}
value={(x) => x}
label={label}
onSelect={(value) => {
props.onWorktreeChange(value ?? MAIN_WORKTREE)
}}
size="normal"
variant="ghost"
class="text-12-medium"
/>
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
</div>
<Show when={sync.project}>
{(project) => (
@@ -7,6 +7,25 @@ import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
}
// 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 playDemoSound = (src: string) => {
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
@@ -36,6 +55,7 @@ export const SettingsGeneral: Component = () => {
{ value: "hack", label: "font.option.hack" },
{ value: "inconsolata", label: "font.option.inconsolata" },
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
{ value: "iosevka", label: "font.option.iosevka" },
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
{ value: "roboto-mono", label: "font.option.robotoMono" },
@@ -210,12 +230,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
@@ -234,12 +254,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
@@ -258,12 +278,12 @@ export const SettingsGeneral: Component = () => {
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playSound(option.src)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
@@ -0,0 +1,405 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogSelectServer } from "./dialog-select-server"
type ServerStatus = { healthy: boolean; version?: string }
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const [loading, setLoading] = createSignal<string | null>(null)
const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>,
})
const servers = createMemo(() => {
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const sortedServers = createMemo(() => {
const list = servers()
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerStatus) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerStatus> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkHealth(url, platform)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
return serverHealthy && !anyMcpIssue
})
const serverCount = createMemo(() => sortedServers().length)
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
createEffect(() => {
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) {
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
return
}
if (result) setDefaultServerUrl(normalizeServerUrl(result))
})
return (
<Popover
triggerAs={Button}
triggerProps={{
variant: "ghost",
class:
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active",
style: { scale: 1 },
}}
trigger={
<div class="flex items-center gap-1.5">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-strong">Status</span>
</div>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={6}
placement="bottom-end"
shift={-136}
>
<div
class="flex items-center gap-1 w-[360px] rounded-xl"
style={{ "box-shadow": "var(--shadow-lg-border-base)" }}
>
<Tabs
aria-label="Server Configurations"
class="tabs"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
style={{
"background-color": "var(--background-strong)",
"border-radius": "12px",
overflow: "hidden",
}}
>
<Tabs.List
data-slot="tablist"
style={{
"background-color": "transparent",
"border-bottom": "none",
padding: "8px 16px 0",
gap: "16px",
height: "40px",
}}
>
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{serverCount() > 0 ? `${serverCount()} ` : ""}Servers
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}LSP
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<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={sortedServers()}>
{(url) => {
const isActive = () => url === server.url
const isDefault = () => url === defaultServerUrl()
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
onMount(() => {
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(url)
const version = status()?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return (
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<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={{
"opacity-50": isBlocked(),
"hover:bg-surface-raised-base-hover": !isBlocked(),
"cursor-not-allowed": isBlocked(),
}}
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(url)
navigate("/")
}}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status()?.healthy === true,
"bg-icon-critical-base": status()?.healthy === false,
"bg-border-weak-base": status() === undefined,
}}
/>
<span ref={nameRef} class="text-14-regular text-text-base truncate">
{serverDisplayName(url)}
</span>
<Show when={status()?.version}>
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
{status()?.version}
</span>
</Show>
<Show when={isDefault()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
Default
</span>
</Show>
<div class="flex-1" />
<Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</button>
</Tooltip>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
Manage servers
</Button>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">No MCP servers configured</div>
}
>
<For each={mcpItems()}>
{(item) => {
const enabled = () => item.status === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)}
disabled={loading() === item.name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "failed",
"bg-border-weak-base": item.status === "disabled",
"bg-icon-warning-base":
item.status === "needs_auth" || item.status === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={loading() === item.name}
onChange={() => toggleMcp(item.name)}
/>
</div>
</button>
)
}}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
LSPs auto-detected from file types
</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
Plugins configured in{" "}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">opencode.json</code>
</div>
}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
</Popover>
)
}
+6
View File
@@ -111,6 +111,8 @@ export const Terminal = (props: TerminalProps) => {
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
@@ -258,6 +260,8 @@ export const Terminal = (props: TerminalProps) => {
})
socket.addEventListener("error", (error) => {
if (disposed) return
if (once.value) return
once.value = true
console.error("WebSocket error:", error)
local.onConnectError?.(error)
})
@@ -266,6 +270,8 @@ export const Terminal = (props: TerminalProps) => {
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
if (once.value) return
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
})
+2 -4
View File
@@ -19,9 +19,6 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const web = createMemo(() => platform.platform === "web")
const getWin = () => {
@@ -81,7 +78,7 @@ export function Titlebar() {
classList={{
"flex items-center w-full min-w-0": true,
"pl-2": !mac(),
"pr-2": !windows(),
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
@@ -145,6 +142,7 @@ export function Titlebar() {
data-tauri-drag-region
/>
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
+7
View File
@@ -38,6 +38,7 @@ function createCommentSession(dir: string, id: string | undefined) {
)
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
const [active, setActive] = createSignal<CommentFocus | null>(null)
const list = (file: string) => store.comments[file] ?? []
@@ -76,6 +77,9 @@ function createCommentSession(dir: string, id: string | undefined) {
focus: createMemo(() => focus()),
setFocus,
clearFocus: () => setFocus(null),
active: createMemo(() => active()),
setActive,
clearActive: () => setActive(null),
}
}
@@ -135,6 +139,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
clearFocus: () => session().clearFocus(),
active: () => session().active(),
setActive: (active: CommentFocus | null) => session().setActive(active),
clearActive: () => session().clearActive(),
}
},
})
+18 -4
View File
@@ -189,6 +189,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const params = useParams()
const language = useLanguage()
const scope = createMemo(() => sdk.directory)
const directory = createMemo(() => sync.data.path.directory)
function normalize(input: string) {
@@ -234,6 +236,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
createEffect(() => {
scope()
inflight.clear()
setStore("file", {})
})
const viewCache = new Map<string, ViewCacheEntry>()
const disposeViews = () => {
@@ -284,12 +292,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const path = normalize(input)
if (!path) return Promise.resolve()
const directory = scope()
const key = `${directory}\n${path}`
const client = sdk.client
ensure(path)
const current = store.file[path]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(path)
const pending = inflight.get(key)
if (pending) return pending
setStore(
@@ -301,9 +313,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}),
)
const promise = sdk.client.file
const promise = client.file
.read({ path })
.then((x) => {
if (scope() !== directory) return
setStore(
"file",
path,
@@ -315,6 +328,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
)
})
.catch((e) => {
if (scope() !== directory) return
setStore(
"file",
path,
@@ -330,10 +344,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
.finally(() => {
inflight.delete(path)
inflight.delete(key)
})
inflight.set(path, promise)
inflight.set(key, promise)
return promise
}
+10 -8
View File
@@ -24,6 +24,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
let buffer: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -41,10 +42,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
queue = []
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
@@ -53,6 +57,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
@@ -61,10 +67,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
@@ -87,12 +89,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(stop)
.finally(flush)
.catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
flush()
})
const sdk = createOpencodeClient({
+48 -54
View File
@@ -119,6 +119,16 @@ type ChildOptions = {
bootstrap?: boolean
}
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -129,6 +139,21 @@ function createGlobalSync() {
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
sdkCache.set(directory, sdk)
return sdk
}
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
@@ -183,7 +208,7 @@ function createGlobalSync() {
setProjectCache("value", projects.map(sanitizeProject))
})
createEffect(async () => {
createEffect(() => {
if (globalStore.reload !== "complete") return
if (bootstrapQueue.length) {
for (const directory of bootstrapQueue) {
@@ -203,14 +228,16 @@ function createGlobalSync() {
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const cache = runWithOwner(owner, () =>
const vcs = runWithOwner(owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(owner, () =>
persisted(
@@ -250,7 +277,7 @@ function createGlobalSync() {
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
vcs: vcsStore.value,
limit: 5,
message: {},
part: {},
@@ -258,6 +285,13 @@ function createGlobalSync() {
children[directory] = child
createEffect(() => {
if (!vcsReady()) return
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
createEffect(() => {
child[1]("projectMeta", meta[0].value)
})
@@ -297,7 +331,6 @@ function createGlobalSync() {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
// Read the current limit at resolve-time so callers that bump the limit while
@@ -348,38 +381,18 @@ function createGlobalSync() {
if (!cache) return
const meta = metaCache.get(directory)
if (!meta) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const sdk = sdkFor(directory)
setStore("status", "loading")
createEffect(() => {
if (!cache.ready()) return
const cached = cache.store.value
if (!cached?.branch) return
setStore("vcs", (value) => value ?? cached)
})
// projectMeta is synced from persisted storage in ensureChild.
// vcs is seeded from persisted storage in ensureChild.
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
const data = x.data!
setStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
@@ -432,10 +445,7 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -464,10 +474,7 @@ function createGlobalSync() {
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -750,13 +757,9 @@ function createGlobalSync() {
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
break
}
}
@@ -796,16 +799,7 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.provider.list().then((x) => {
const data = x.data!
setGlobalStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
+127 -55
View File
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -209,6 +209,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
const colorRequested = new Map<string, AvatarColorKey>()
function pickAvailableColor(used: Set<string>): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
@@ -267,17 +268,36 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return map
})
createEffect(() => {
const rootFor = (directory: string) => {
const map = roots()
if (map.size === 0) return
if (map.size === 0) return directory
const visited = new Set<string>()
const chain = [directory]
while (chain.length) {
const current = chain[chain.length - 1]
if (!current) return directory
const next = map.get(current)
if (!next) return current
if (visited.has(next)) return directory
visited.add(next)
chain.push(next)
}
return directory
}
createEffect(() => {
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
const root = rootFor(project.worktree)
if (root === project.worktree) continue
server.projects.close(project.worktree)
@@ -305,13 +325,21 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!globalSync.ready) return
if (globalSync.ready) {
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
globalSync.project.icon(project.worktree, project.icon?.override)
}
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
globalSync.project.icon(project.worktree, project.icon?.override)
}
})
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
for (const project of projects) {
if (project.icon?.color) colorRequested.delete(project.worktree)
}
const used = new Set<string>()
@@ -322,18 +350,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const project of projects) {
if (project.icon?.color) continue
const existing = colors[project.worktree]
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
if (!existing) {
used.add(color)
setColors(project.worktree, color)
setColors(worktree, color)
}
if (!project.id) continue
const requested = colorRequested.get(worktree)
if (requested === color) continue
colorRequested.set(worktree, color)
if (project.id === "global") {
globalSync.project.meta(project.worktree, { icon: { color } })
globalSync.project.meta(worktree, { icon: { color } })
continue
}
void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
void globalSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
@@ -350,7 +389,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
const root = roots().get(directory) ?? directory
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
@@ -384,7 +423,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
@@ -432,10 +471,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x)
},
},
view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
touch(key())
scroll.seed(key())
createEffect(
on(
key,
(value) => {
touch(value)
scroll.seed(value)
},
{ defer: true },
),
)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
@@ -465,10 +518,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return {
scroll(tab: string) {
return scroll.scroll(sessionKey, tab)
return scroll.scroll(key(), tab)
},
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(sessionKey, tab, pos)
scroll.setScroll(key(), tab, pos)
},
terminal: {
opened: terminalOpened,
@@ -497,9 +550,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", sessionKey, {
setStore("sessionView", session, {
scroll: {},
reviewOpen: open,
})
@@ -507,93 +561,111 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
if (same(current.reviewOpen, open)) return
setStore("sessionView", sessionKey, "reviewOpen", open)
setStore("sessionView", session, "reviewOpen", open)
},
},
}
},
tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
tabs(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
touch(key())
createEffect(
on(
key,
(value) => {
touch(value)
},
{ defer: true },
),
)
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all),
setActive(tab: string | undefined) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
const session = key()
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab })
} else {
setStore("sessionTabs", sessionKey, "active", tab)
setStore("sessionTabs", session, "active", tab)
}
},
setAll(all: string[]) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: undefined })
const session = key()
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all, active: undefined })
} else {
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", session, "all", all)
}
},
async open(tab: string) {
const current = store.sessionTabs[sessionKey] ?? { all: [] }
const session = key()
const current = store.sessionTabs[session] ?? { all: [] }
if (tab === "review") {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab })
return
}
setStore("sessionTabs", sessionKey, "active", tab)
setStore("sessionTabs", session, "active", tab)
return
}
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: tab })
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", sessionKey, "active", tab)
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", tab)
return
}
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [tab], active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
setStore("sessionTabs", session, "all", [...current.all, tab])
setStore("sessionTabs", session, "active", tab)
return
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: current.all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "active", tab)
setStore("sessionTabs", session, "active", tab)
},
close(tab: string) {
const current = store.sessionTabs[sessionKey]
const session = key()
const current = store.sessionTabs[session]
if (!current) return
const all = current.all.filter((x) => x !== tab)
batch(() => {
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", session, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", sessionKey, "active", next)
setStore("sessionTabs", session, "active", next)
})
},
move(tab: string, to: number) {
const current = store.sessionTabs[sessionKey]
const session = key()
const current = store.sessionTabs[session]
if (!current) return
const index = current.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"sessionTabs",
sessionKey,
session,
"all",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
+31 -12
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -126,7 +126,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey>
model: Record<string, ModelKey | undefined>
}>({
model: {},
})
@@ -182,7 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo(() => {
const fallbackModel = createMemo<ModelKey | undefined>(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
@@ -199,16 +199,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const defaults = providers.default()
for (const p of providers.connected()) {
if (p.id in providers.default()) {
return {
providerID: p.id,
modelID: providers.default()[p.id],
}
const configured = defaults[p.id]
if (configured) {
const key = { providerID: p.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(p.models)[0]
if (!first) continue
const key = { providerID: p.id, modelID: first.id }
if (isModelValid(key)) return key
}
throw new Error("No default model found")
return undefined
})
const current = createMemo(() => {
@@ -266,7 +271,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
const currentAgent = agent.current()
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
@@ -338,6 +344,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
})
const scope = createMemo(() => sdk.directory)
createEffect(() => {
scope()
setStore("node", {})
})
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
@@ -394,10 +406,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
const load = async (path: string) => {
const directory = scope()
const client = sdk.client
const relativePath = relative(path)
await sdk.client.file
await client.file
.read({ path: relativePath })
.then((x) => {
if (scope() !== directory) return
if (!store.node[relativePath]) return
setStore(
"node",
@@ -409,6 +424,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})
.catch((e) => {
if (scope() !== directory) return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
@@ -453,9 +469,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
return sdk.client.file
const directory = scope()
const client = sdk.client
return client.file
.list({ path: path + "/" })
.then((x) => {
if (scope() !== directory) return
setStore(
"node",
produce((draft) => {
+1
View File
@@ -44,6 +44,7 @@ export type FileContextItem = {
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
+28 -11
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
@@ -10,22 +10,39 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
init: (props: { directory: string }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: props.directory,
throwOnError: true,
})
const directory = createMemo(() => props.directory)
const client = createMemo(() =>
createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(),
throwOnError: true,
}),
)
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
const unsub = globalSDK.event.on(props.directory, (event) => {
emitter.emit(event.type, event)
createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
})
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
return {
get directory() {
return directory()
},
get client() {
return client()
},
event: emitter,
get url() {
return globalSDK.url
},
}
},
})
+1
View File
@@ -65,6 +65,7 @@ const monoFonts: Record<string, string> = {
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
+113 -57
View File
@@ -7,13 +7,20 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const globalSync = useGlobalSync()
const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
type Child = ReturnType<(typeof globalSync)["child"]>
type Store = Child[0]
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
@@ -25,6 +32,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
@@ -35,66 +43,75 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return Math.ceil(count / chunk) * chunk
}
const hydrateMessages = (sessionID: string) => {
if (meta.limit[sessionID] !== undefined) return
const hydrateMessages = (directory: string, store: Store, sessionID: string) => {
const key = keyFor(directory, sessionID)
if (meta.limit[key] !== undefined) return
const messages = store.message[sessionID]
if (!messages) return
const limit = limitFor(messages.length)
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, messages.length < limit)
setMeta("limit", key, limit)
setMeta("complete", key, messages.length < limit)
}
const loadMessages = async (sessionID: string, limit: number) => {
if (meta.loading[sessionID]) return
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", sessionID, true)
await retry(() => sdk.client.session.messages({ sessionID, limit }))
setMeta("loading", key, true)
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
input.setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, next.length < limit)
setMeta("limit", key, input.limit)
setMeta("complete", key, next.length < input.limit)
})
})
.finally(() => {
setMeta("loading", sessionID, false)
setMeta("loading", key, false)
})
}
return {
data: store,
set: setStore,
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return store.status
return current()[0].status
},
get ready() {
return store.status !== "loading"
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
@@ -116,7 +133,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: input.agent,
model: input.model,
}
setStore(
current()[1](
produce((draft) => {
const messages = draft.message[input.sessionID]
if (!messages) {
@@ -125,28 +142,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
async sync(sessionID: string) {
const hasSession = getSession(sessionID) !== undefined
hydrateMessages(sessionID)
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const hasSession = (() => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
return match.found
})()
hydrateMessages(directory, store, sessionID)
const hasMessages = store.message[sessionID] !== undefined
if (hasSession && hasMessages) return
const pending = inflight.get(sessionID)
const key = keyFor(directory, sessionID)
const pending = inflight.get(key)
if (pending) return pending
const limit = meta.limit[sessionID] ?? chunk
const limit = meta.limit[key] ?? chunk
const sessionReq = hasSession
? Promise.resolve()
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
: retry(() => client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
@@ -162,83 +184,117 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
})
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
const messagesReq = hasMessages
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(sessionID)
inflight.delete(key)
})
inflight.set(sessionID, promise)
inflight.set(key, promise)
return promise
},
async diff(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.session_diff[sessionID] !== undefined) return
const pending = inflightDiff.get(sessionID)
const key = keyFor(directory, sessionID)
const pending = inflightDiff.get(key)
if (pending) return pending
const promise = retry(() => sdk.client.session.diff({ sessionID }))
const promise = retry(() => client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(sessionID)
inflightDiff.delete(key)
})
inflightDiff.set(sessionID, promise)
inflightDiff.set(key, promise)
return promise
},
async todo(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const pending = inflightTodo.get(sessionID)
const key = keyFor(directory, sessionID)
const pending = inflightTodo.get(key)
if (pending) return pending
const promise = retry(() => sdk.client.session.todo({ sessionID }))
const promise = retry(() => client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(sessionID)
inflightTodo.delete(key)
})
inflightTodo.set(sessionID, promise)
inflightTodo.set(key, promise)
return promise
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[sessionID] === undefined) return false
if (meta.complete[sessionID]) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return true
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count = chunk) {
if (meta.loading[sessionID]) return
if (meta.complete[sessionID]) return
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
if (meta.loading[key]) return
if (meta.complete[key]) return
const current = meta.limit[sessionID] ?? chunk
await loadMessages(sessionID, current + count)
const currentLimit = meta.limit[key] ?? chunk
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: currentLimit + count,
})
},
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await sdk.client.session.list().then((x) => {
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => store.session.length >= store.limit),
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
@@ -249,7 +305,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
absolute,
get directory() {
return store.path.directory
return current()[0].path.directory
},
}
},
+11 -7
View File
@@ -13,7 +13,6 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
error?: boolean
}
const WORKSPACE_KEY = "__workspace__"
@@ -151,13 +150,18 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
...pty,
...clone.data,
})
if (active) {
setStore("active", clone.data.id)
}
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
+13 -1
View File
@@ -88,6 +88,8 @@ export const dict = {
"dialog.provider.group.other": "آخر",
"dialog.provider.tag.recommended": "موصى به",
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
"dialog.model.select.title": "تحديد نموذج",
"dialog.model.search.placeholder": "البحث عن نماذج",
@@ -221,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
"dialog.mcp.empty": "لم يتم تكوين MCPs",
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل",
"mcp.status.failed": "فشل",
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
@@ -240,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "تعذر الاتصال بالخادم",
"dialog.server.add.checking": "جارٍ التحقق...",
"dialog.server.add.button": "إضافة",
"dialog.server.add.button": "إضافة خادم",
"dialog.server.default.title": "الخادم الافتراضي",
"dialog.server.default.description":
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -249,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "مسح",
"dialog.server.action.remove": "إزالة الخادم",
"dialog.server.menu.edit": "تعديل",
"dialog.server.menu.default": "تعيين كافتراضي",
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
"dialog.server.menu.delete": "حذف",
"dialog.server.current": "الخادم الحالي",
"dialog.server.status.default": "افتراضي",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
+2
View File
@@ -88,6 +88,8 @@ export const dict = {
"dialog.provider.group.other": "Outro",
"dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
"dialog.model.select.title": "Selecionar modelo",
"dialog.model.search.placeholder": "Buscar modelos",
+13 -1
View File
@@ -86,6 +86,8 @@ export const dict = {
"dialog.provider.group.other": "Andre",
"dialog.provider.tag.recommended": "Anbefalet",
"dialog.provider.anthropic.note": "Forbind med Claude Pro/Max eller API-nøgle",
"dialog.provider.openai.note": "Forbind med ChatGPT Pro/Plus eller API-nøgle",
"dialog.provider.copilot.note": "Forbind med Copilot eller API-nøgle",
"dialog.model.select.title": "Vælg model",
"dialog.model.search.placeholder": "Søg modeller",
@@ -203,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
"dialog.mcp.empty": "Ingen MCP'er konfigureret",
"dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper",
"dialog.plugins.empty": "Plugins konfigureret i opencode.json",
"mcp.status.connected": "forbundet",
"mcp.status.failed": "mislykkedes",
"mcp.status.needs_auth": "kræver godkendelse",
@@ -222,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Kunne ikke forbinde til server",
"dialog.server.add.checking": "Tjekker...",
"dialog.server.add.button": "Tilføj",
"dialog.server.add.button": "Tilføj server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@@ -231,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Ryd",
"dialog.server.action.remove": "Fjern server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sæt som standard",
"dialog.server.menu.defaultRemove": "Fjern som standard",
"dialog.server.menu.delete": "Slet",
"dialog.server.current": "Nuværende server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger projekt",
"dialog.project.edit.name": "Navn",
"dialog.project.edit.icon": "Ikon",
+13 -1
View File
@@ -90,6 +90,8 @@ export const dict = {
"dialog.provider.group.other": "Andere",
"dialog.provider.tag.recommended": "Empfohlen",
"dialog.provider.anthropic.note": "Mit Claude Pro/Max oder API-Schlüssel verbinden",
"dialog.provider.openai.note": "Mit ChatGPT Pro/Plus oder API-Schlüssel verbinden",
"dialog.provider.copilot.note": "Mit Copilot oder API-Schlüssel verbinden",
"dialog.model.select.title": "Modell auswählen",
"dialog.model.search.placeholder": "Modelle durchsuchen",
@@ -208,6 +210,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
"dialog.mcp.empty": "Keine MCPs konfiguriert",
"dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt",
"dialog.plugins.empty": "In opencode.json konfigurierte Plugins",
"mcp.status.connected": "verbunden",
"mcp.status.failed": "fehlgeschlagen",
"mcp.status.needs_auth": "benötigt Authentifizierung",
@@ -227,7 +232,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
"dialog.server.add.checking": "Prüfen...",
"dialog.server.add.button": "Hinzufügen",
"dialog.server.add.button": "Server hinzufügen",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
@@ -236,6 +241,13 @@ export const dict = {
"dialog.server.default.clear": "Löschen",
"dialog.server.action.remove": "Server entfernen",
"dialog.server.menu.edit": "Bearbeiten",
"dialog.server.menu.default": "Als Standard festlegen",
"dialog.server.menu.defaultRemove": "Standard entfernen",
"dialog.server.menu.delete": "Löschen",
"dialog.server.current": "Aktueller Server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Projekt bearbeiten",
"dialog.project.edit.name": "Name",
"dialog.project.edit.icon": "Icon",
+14 -1
View File
@@ -88,6 +88,8 @@ export const dict = {
"dialog.provider.group.other": "Other",
"dialog.provider.tag.recommended": "Recommended",
"dialog.provider.anthropic.note": "Connect with Claude Pro/Max or API key",
"dialog.provider.openai.note": "Connect with ChatGPT Pro/Plus or API key",
"dialog.provider.copilot.note": "Connect with Copilot or API key",
"dialog.model.select.title": "Select model",
"dialog.model.search.placeholder": "Search models",
@@ -221,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
"mcp.status.connected": "connected",
"mcp.status.failed": "failed",
"mcp.status.needs_auth": "needs auth",
@@ -240,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Could not connect to server",
"dialog.server.add.checking": "Checking...",
"dialog.server.add.button": "Add",
"dialog.server.add.button": "Add server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",
@@ -249,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Clear",
"dialog.server.action.remove": "Remove server",
"dialog.server.menu.edit": "Edit",
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"dialog.project.edit.title": "Edit project",
"dialog.project.edit.name": "Name",
"dialog.project.edit.icon": "Icon",
@@ -492,6 +504,7 @@ export const dict = {
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
+13 -1
View File
@@ -86,6 +86,8 @@ export const dict = {
"dialog.provider.group.other": "Otro",
"dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.anthropic.note": "Conectar con Claude Pro/Max o clave API",
"dialog.provider.openai.note": "Conectar con ChatGPT Pro/Plus o clave API",
"dialog.provider.copilot.note": "Conectar con Copilot o clave API",
"dialog.model.select.title": "Seleccionar modelo",
"dialog.model.search.placeholder": "Buscar modelos",
@@ -203,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
"dialog.mcp.empty": "No hay MCPs configurados",
"dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo",
"dialog.plugins.empty": "Plugins configurados en opencode.json",
"mcp.status.connected": "conectado",
"mcp.status.failed": "fallido",
"mcp.status.needs_auth": "necesita auth",
@@ -222,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "No se pudo conectar al servidor",
"dialog.server.add.checking": "Comprobando...",
"dialog.server.add.button": "Añadir",
"dialog.server.add.button": "Añadir servidor",
"dialog.server.default.title": "Servidor predeterminado",
"dialog.server.default.description":
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
@@ -231,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Limpiar",
"dialog.server.action.remove": "Eliminar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Establecer como predeterminado",
"dialog.server.menu.defaultRemove": "Quitar predeterminado",
"dialog.server.menu.delete": "Eliminar",
"dialog.server.current": "Servidor actual",
"dialog.server.status.default": "Predeterminado",
"dialog.project.edit.title": "Editar proyecto",
"dialog.project.edit.name": "Nombre",
"dialog.project.edit.icon": "Icono",
+13 -1
View File
@@ -86,6 +86,8 @@ export const dict = {
"dialog.provider.group.other": "Autre",
"dialog.provider.tag.recommended": "Recommandé",
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
"dialog.model.select.title": "Sélectionner un modèle",
"dialog.model.search.placeholder": "Rechercher des modèles",
@@ -203,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} sur {{total}} activés",
"dialog.mcp.empty": "Aucun MCP configuré",
"dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier",
"dialog.plugins.empty": "Plugins configurés dans opencode.json",
"mcp.status.connected": "connecté",
"mcp.status.failed": "échoué",
"mcp.status.needs_auth": "nécessite auth",
@@ -222,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Impossible de se connecter au serveur",
"dialog.server.add.checking": "Vérification...",
"dialog.server.add.button": "Ajouter",
"dialog.server.add.button": "Ajouter un serveur",
"dialog.server.default.title": "Serveur par défaut",
"dialog.server.default.description":
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
@@ -231,6 +236,13 @@ export const dict = {
"dialog.server.default.clear": "Effacer",
"dialog.server.action.remove": "Supprimer le serveur",
"dialog.server.menu.edit": "Modifier",
"dialog.server.menu.default": "Définir par défaut",
"dialog.server.menu.defaultRemove": "Supprimer par défaut",
"dialog.server.menu.delete": "Supprimer",
"dialog.server.current": "Serveur actuel",
"dialog.server.status.default": "Défaut",
"dialog.project.edit.title": "Modifier le projet",
"dialog.project.edit.name": "Nom",
"dialog.project.edit.icon": "Icône",
+13 -1
View File
@@ -86,6 +86,8 @@ export const dict = {
"dialog.provider.group.other": "その他",
"dialog.provider.tag.recommended": "推奨",
"dialog.provider.anthropic.note": "Claude Pro/MaxまたはAPIキーで接続",
"dialog.provider.openai.note": "ChatGPT Pro/PlusまたはAPIキーで接続",
"dialog.provider.copilot.note": "CopilotまたはAPIキーで接続",
"dialog.model.select.title": "モデルを選択",
"dialog.model.search.placeholder": "モデルを検索",
@@ -202,6 +204,9 @@ export const dict = {
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
"dialog.mcp.empty": "MCPが設定されていません",
"dialog.lsp.empty": "ファイルタイプから自動検出されたLSP",
"dialog.plugins.empty": "opencode.jsonで設定されたプラグイン",
"mcp.status.connected": "接続済み",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "認証が必要",
@@ -221,7 +226,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "サーバーに接続できませんでした",
"dialog.server.add.checking": "確認中...",
"dialog.server.add.button": "追加",
"dialog.server.add.button": "サーバーを追加",
"dialog.server.default.title": "デフォルトサーバー",
"dialog.server.default.description":
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
@@ -230,6 +235,13 @@ export const dict = {
"dialog.server.default.clear": "クリア",
"dialog.server.action.remove": "サーバーを削除",
"dialog.server.menu.edit": "編集",
"dialog.server.menu.default": "デフォルトに設定",
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
"dialog.server.menu.delete": "削除",
"dialog.server.current": "現在のサーバー",
"dialog.server.status.default": "デフォルト",
"dialog.project.edit.title": "プロジェクトを編集",
"dialog.project.edit.name": "名前",
"dialog.project.edit.icon": "アイコン",
+13 -1
View File
@@ -90,6 +90,8 @@ export const dict = {
"dialog.provider.group.other": "기타",
"dialog.provider.tag.recommended": "추천",
"dialog.provider.anthropic.note": "Claude Pro/Max 또는 API 키로 연결",
"dialog.provider.openai.note": "ChatGPT Pro/Plus 또는 API 키로 연결",
"dialog.provider.copilot.note": "Copilot 또는 API 키로 연결",
"dialog.model.select.title": "모델 선택",
"dialog.model.search.placeholder": "모델 검색",
@@ -206,6 +208,9 @@ export const dict = {
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
"dialog.mcp.empty": "구성된 MCP 없음",
"dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP",
"dialog.plugins.empty": "opencode.json에 구성된 플러그인",
"mcp.status.connected": "연결됨",
"mcp.status.failed": "실패",
"mcp.status.needs_auth": "인증 필요",
@@ -225,7 +230,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "서버에 연결할 수 없습니다",
"dialog.server.add.checking": "확인 중...",
"dialog.server.add.button": "추가",
"dialog.server.add.button": "서버 추가",
"dialog.server.default.title": "기본 서버",
"dialog.server.default.description":
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
@@ -234,6 +239,13 @@ export const dict = {
"dialog.server.default.clear": "지우기",
"dialog.server.action.remove": "서버 제거",
"dialog.server.menu.edit": "편집",
"dialog.server.menu.default": "기본값으로 설정",
"dialog.server.menu.defaultRemove": "기본값 제거",
"dialog.server.menu.delete": "삭제",
"dialog.server.current": "현재 서버",
"dialog.server.status.default": "기본값",
"dialog.project.edit.title": "프로젝트 편집",
"dialog.project.edit.name": "이름",
"dialog.project.edit.icon": "아이콘",
+13 -1
View File
@@ -91,6 +91,8 @@ export const dict = {
"dialog.provider.group.other": "Andre",
"dialog.provider.tag.recommended": "Anbefalt",
"dialog.provider.anthropic.note": "Koble til med Claude Pro/Max eller API-nøkkel",
"dialog.provider.openai.note": "Koble til med ChatGPT Pro/Plus eller API-nøkkel",
"dialog.provider.copilot.note": "Koble til med Copilot eller API-nøkkel",
"dialog.model.select.title": "Velg modell",
"dialog.model.search.placeholder": "Søk etter modeller",
@@ -224,6 +226,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
"dialog.mcp.empty": "Ingen MCP-er konfigurert",
"dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper",
"dialog.plugins.empty": "Plugins konfigurert i opencode.json",
"mcp.status.connected": "tilkoblet",
"mcp.status.failed": "mislyktes",
"mcp.status.needs_auth": "trenger autentisering",
@@ -243,7 +248,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Kunne ikke koble til server",
"dialog.server.add.checking": "Sjekker...",
"dialog.server.add.button": "Legg til",
"dialog.server.add.button": "Legg til server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
@@ -252,6 +257,13 @@ export const dict = {
"dialog.server.default.clear": "Tøm",
"dialog.server.action.remove": "Fjern server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sett som standard",
"dialog.server.menu.defaultRemove": "Fjern standard",
"dialog.server.menu.delete": "Slett",
"dialog.server.current": "Gjeldende server",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger prosjekt",
"dialog.project.edit.name": "Navn",
"dialog.project.edit.icon": "Ikon",
+13 -1
View File
@@ -88,6 +88,8 @@ export const dict = {
"dialog.provider.group.other": "Inne",
"dialog.provider.tag.recommended": "Zalecane",
"dialog.provider.anthropic.note": "Połącz z Claude Pro/Max lub kluczem API",
"dialog.provider.openai.note": "Połącz z ChatGPT Pro/Plus lub kluczem API",
"dialog.provider.copilot.note": "Połącz z Copilot lub kluczem API",
"dialog.model.select.title": "Wybierz model",
"dialog.model.search.placeholder": "Szukaj modeli",
@@ -221,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} z {{total}} włączone",
"dialog.mcp.empty": "Brak skonfigurowanych MCP",
"dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików",
"dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json",
"mcp.status.connected": "połączono",
"mcp.status.failed": "niepowodzenie",
"mcp.status.needs_auth": "wymaga autoryzacji",
@@ -240,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Nie można połączyć się z serwerem",
"dialog.server.add.checking": "Sprawdzanie...",
"dialog.server.add.button": "Dodaj",
"dialog.server.add.button": "Dodaj serwer",
"dialog.server.default.title": "Domyślny serwer",
"dialog.server.default.description":
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
@@ -249,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Wyczyść",
"dialog.server.action.remove": "Usuń serwer",
"dialog.server.menu.edit": "Edytuj",
"dialog.server.menu.default": "Ustaw jako domyślny",
"dialog.server.menu.defaultRemove": "Usuń domyślny",
"dialog.server.menu.delete": "Usuń",
"dialog.server.current": "Obecny serwer",
"dialog.server.status.default": "Domyślny",
"dialog.project.edit.title": "Edytuj projekt",
"dialog.project.edit.name": "Nazwa",
"dialog.project.edit.icon": "Ikona",
+13 -1
View File
@@ -88,6 +88,8 @@ export const dict = {
"dialog.provider.group.other": "Другие",
"dialog.provider.tag.recommended": "Рекомендуемые",
"dialog.provider.anthropic.note": "Подключитесь с помощью Claude Pro/Max или API ключа",
"dialog.provider.openai.note": "Подключитесь с помощью ChatGPT Pro/Plus или API ключа",
"dialog.provider.copilot.note": "Подключитесь с помощью Copilot или API ключа",
"dialog.model.select.title": "Выбрать модель",
"dialog.model.search.placeholder": "Поиск моделей",
@@ -221,6 +223,9 @@ export const dict = {
"dialog.mcp.description": "{{enabled}} из {{total}} включено",
"dialog.mcp.empty": "MCP не настроены",
"dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов",
"dialog.plugins.empty": "Плагины настроены в opencode.json",
"mcp.status.connected": "подключено",
"mcp.status.failed": "ошибка",
"mcp.status.needs_auth": "требуется авторизация",
@@ -240,7 +245,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Не удалось подключиться к серверу",
"dialog.server.add.checking": "Проверка...",
"dialog.server.add.button": "Добавить",
"dialog.server.add.button": "Добавить сервер",
"dialog.server.default.title": "Сервер по умолчанию",
"dialog.server.default.description":
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
@@ -249,6 +254,13 @@ export const dict = {
"dialog.server.default.clear": "Очистить",
"dialog.server.action.remove": "Удалить сервер",
"dialog.server.menu.edit": "Редактировать",
"dialog.server.menu.default": "Сделать по умолчанию",
"dialog.server.menu.defaultRemove": "Удалить по умолчанию",
"dialog.server.menu.delete": "Удалить",
"dialog.server.current": "Текущий сервер",
"dialog.server.status.default": "По умолч.",
"dialog.project.edit.title": "Редактировать проект",
"dialog.project.edit.name": "Название",
"dialog.project.edit.icon": "Иконка",
+13 -1
View File
@@ -90,6 +90,8 @@ export const dict = {
"dialog.provider.group.other": "其他",
"dialog.provider.tag.recommended": "推荐",
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接",
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接",
"dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接",
"dialog.model.select.title": "选择模型",
"dialog.model.search.placeholder": "搜索模型",
@@ -203,6 +205,9 @@ export const dict = {
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
"dialog.mcp.empty": "未配置 MCPs",
"dialog.lsp.empty": "已从文件类型自动检测到 LSPs",
"dialog.plugins.empty": "在 opencode.json 中配置的插件",
"mcp.status.connected": "已连接",
"mcp.status.failed": "失败",
"mcp.status.needs_auth": "需要授权",
@@ -222,7 +227,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "无法连接到服务器",
"dialog.server.add.checking": "检查中...",
"dialog.server.add.button": "添加",
"dialog.server.add.button": "添加服务器",
"dialog.server.default.title": "默认服务器",
"dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
"dialog.server.default.none": "未选择服务器",
@@ -230,6 +235,13 @@ export const dict = {
"dialog.server.default.clear": "清除",
"dialog.server.action.remove": "移除服务器",
"dialog.server.menu.edit": "编辑",
"dialog.server.menu.default": "设为默认",
"dialog.server.menu.defaultRemove": "取消默认",
"dialog.server.menu.delete": "删除",
"dialog.server.current": "当前服务器",
"dialog.server.status.default": "默认",
"dialog.project.edit.title": "编辑项目",
"dialog.project.edit.name": "名称",
"dialog.project.edit.icon": "图标",
+13 -1
View File
@@ -90,6 +90,8 @@ export const dict = {
"dialog.provider.group.other": "其他",
"dialog.provider.tag.recommended": "推薦",
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 金鑰連線",
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 金鑰連線",
"dialog.provider.copilot.note": "使用 Copilot 或 API 金鑰連線",
"dialog.model.select.title": "選擇模型",
"dialog.model.search.placeholder": "搜尋模型",
@@ -205,6 +207,9 @@ export const dict = {
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
"dialog.mcp.empty": "未設定 MCP",
"dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs",
"dialog.plugins.empty": "在 opencode.json 中設定的外掛程式",
"mcp.status.connected": "已連線",
"mcp.status.failed": "失敗",
"mcp.status.needs_auth": "需要授權",
@@ -224,7 +229,7 @@ export const dict = {
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "無法連線到伺服器",
"dialog.server.add.checking": "檢查中...",
"dialog.server.add.button": "新增",
"dialog.server.add.button": "新增伺服器",
"dialog.server.default.title": "預設伺服器",
"dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
"dialog.server.default.none": "未選擇伺服器",
@@ -232,6 +237,13 @@ export const dict = {
"dialog.server.default.clear": "清除",
"dialog.server.action.remove": "移除伺服器",
"dialog.server.menu.edit": "編輯",
"dialog.server.menu.default": "設為預設",
"dialog.server.menu.defaultRemove": "取消預設",
"dialog.server.menu.delete": "刪除",
"dialog.server.current": "目前伺服器",
"dialog.server.status.default": "預設",
"dialog.project.edit.title": "編輯專案",
"dialog.project.edit.name": "名稱",
"dialog.project.edit.icon": "圖示",
+1 -1
View File
@@ -16,7 +16,7 @@ export default function Layout(props: ParentProps) {
return base64Decode(params.dir!)
})
return (
<Show when={params.dir} keyed>
<Show when={params.dir}>
<SDKProvider directory={directory()}>
<SyncProvider>
{iife(() => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { useLayout } from "@/context/layout"
+81 -27
View File
@@ -56,6 +56,7 @@ import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -88,11 +89,6 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
xlQuery.addEventListener("change", handleViewportChange)
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
const params = useParams()
const [autoselect, setAutoselect] = createSignal(!params.dir)
@@ -332,6 +328,18 @@ export default function Layout(props: ParentProps) {
const cooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(e.name)
return
}
if (e.details?.type === "worktree.failed") {
setBusy(e.name, false)
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
return
}
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const title =
e.details.type === "permission.asked"
@@ -537,8 +545,6 @@ export default function Layout(props: ParentProps) {
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
const workspaceSetting = createMemo(() => {
const project = currentProject()
if (!project) return false
@@ -551,6 +557,7 @@ export default function Layout(props: ParentProps) {
const project = currentProject()
if (!project) return
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
if (!existing) {
@@ -558,9 +565,9 @@ export default function Layout(props: ParentProps) {
return
}
const keep = existing.filter((d) => dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d))
const merged = [...keep, ...missing]
const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
const merged = [local, ...missing, ...keep]
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
@@ -710,7 +717,8 @@ export default function Layout(props: ParentProps) {
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const cached = untrack(() => store.message[session.id] !== undefined)
if (cached) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
@@ -841,14 +849,34 @@ export default function Layout(props: ParentProps) {
setStore(
produce((draft) => {
const removed = new Set<string>([session.id])
const collect = (parentID: string) => {
for (const item of draft.session) {
if (item.parentID !== parentID) continue
removed.add(item.id)
collect(item.id)
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 = [session.id]
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)
}
}
collect(session.id)
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
@@ -1246,9 +1274,9 @@ export default function Layout(props: ParentProps) {
})
})
const handleDelete = async () => {
await deleteWorkspace(props.directory)
const handleDelete = () => {
dialog.close()
void deleteWorkspace(props.directory)
}
const description = () => {
@@ -1434,17 +1462,22 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject()
const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const existing = store.workspaceOrder[project.worktree]
if (!existing) return next
if (!existing) return extra ? [...dirs, extra] : dirs
const keep = existing.filter((d) => next.includes(d))
const missing = next.filter((d) => !existing.includes(d))
return [...keep, ...missing]
const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
}
function handleWorkspaceDragStart(event: unknown) {
@@ -2136,11 +2169,11 @@ export default function Layout(props: ParentProps) {
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
layout.sidebar.open()
setOpen(false)
if (selected()) {
setOpen(false)
return
}
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
@@ -2237,8 +2270,29 @@ export default function Layout(props: ParentProps) {
if (!created?.directory) return
const local = current.worktree
const key = workspaceKey(created.directory)
const root = workspaceKey(local)
setBusy(created.directory, true)
WorktreeState.pending(created.directory)
setStore("workspaceExpanded", key, true)
if (key !== created.directory) {
setStore("workspaceExpanded", created.directory, true)
}
setStore("workspaceOrder", current.worktree, (prev) => {
const existing = prev ?? []
const next = existing.filter((item) => {
const id = workspaceKey(item)
if (id === root) return false
return id !== key
})
return [local, created.directory, ...next]
})
globalSync.child(created.directory)
navigate(`/${base64Encode(created.directory)}/session`)
layout.mobileSidebar.hide()
}
command.register(() => [
+251 -214
View File
@@ -1,16 +1,4 @@
import {
For,
Index,
onCleanup,
onMount,
Show,
Match,
Switch,
createMemo,
createEffect,
on,
createSignal,
} from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -27,6 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
@@ -39,7 +28,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -65,7 +54,6 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
@@ -101,7 +89,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
const sdk = useSDK()
const readFile = (path: string) => {
const readFile = async (path: string) => {
return sdk.client.file
.read({ path })
.then((x) => x.data)
@@ -190,7 +178,6 @@ export default function Page() {
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@@ -199,8 +186,8 @@ export default function Page() {
const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
if (import.meta.env.DEV) {
createEffect(
@@ -316,12 +303,22 @@ export default function Page() {
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}, emptyUserMessages)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{
equals: same,
},
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
@@ -347,13 +344,19 @@ export default function Page() {
promptHeight: 0,
})
const renderedUserMessages = createMemo(() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
}, emptyUserMessages)
const renderedUserMessages = createMemo(
() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -519,6 +522,7 @@ export default function Page() {
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? selectionPreview(input.file, selection)
@@ -533,6 +537,7 @@ export default function Page() {
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
@@ -729,7 +734,7 @@ export default function Page() {
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
const message = userMessages().findLast((x) => !revert || x.id < revert)
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
@@ -739,7 +744,7 @@ export default function Page() {
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = userMessages().findLast((x) => x.id < message.id)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
@@ -761,14 +766,14 @@ export default function Page() {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
@@ -1246,19 +1251,40 @@ export default function Page() {
autoScroll.forceScrollToBottom()
}
const closestMessage = (node: Element | null): HTMLElement | null => {
if (!node) return null
const match = node.closest?.("[data-message-id]") as HTMLElement | null
if (match) return match
const root = node.getRootNode?.()
if (root instanceof ShadowRoot) return closestMessage(root.host)
return null
}
const getActiveMessageId = (container: HTMLDivElement) => {
const rect = container.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
const hit = document.elementFromPoint(x, y)
const host = closestMessage(hit)
const id = host?.dataset.messageId
if (id) return id
// Fallback: DOM query (handles edge hit-testing cases)
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let id: string | undefined
let last: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
id = next
last = next
}
return id
return last
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
@@ -1401,7 +1427,7 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
Session
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="review"
@@ -1410,8 +1436,10 @@ export default function Page() {
onClick={() => setStore("mobileTab", "review")}
>
<Switch>
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
<Match when={true}>Review</Match>
<Match when={hasReview()}>
{language.t("session.review.filesChanged", { count: reviewCount() })}
</Match>
<Match when={true}>{language.t("session.tab.review")}</Match>
</Switch>
</Tabs.Trigger>
</Tabs.List>
@@ -1422,7 +1450,7 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true,
"flex-1 md:flex-none pt-6 md:pt-3": true,
}}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
@@ -1441,13 +1469,17 @@ export default function Page() {
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
fallback={
<div class="px-4 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onLineComment={addCommentToContext}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@@ -1467,7 +1499,9 @@ export default function Page() {
<Match when={true}>
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
<div class="text-13-regular text-text-weak max-w-56">
{language.t("session.review.empty")}
</div>
</div>
</Match>
</Switch>
@@ -1509,9 +1543,9 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
>
<Show when={info()?.title}>
<Show when={info()?.title || info()?.parentID}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
@@ -1520,8 +1554,21 @@ export default function Page() {
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<div class="h-10 flex items-center">
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
<div class="h-10 flex items-center gap-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title}>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
</Show>
</div>
</div>
</Show>
@@ -1635,11 +1682,11 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
"w-full px-4 pointer-events-auto": true,
"md:max-w-200": !showTabs(),
}}
>
@@ -1699,7 +1746,7 @@ export default function Page() {
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<div>{language.t("session.tab.review")}</div>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{info()?.summary?.files ?? 0}
@@ -1727,7 +1774,7 @@ export default function Page() {
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>Context</div>
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
@@ -1736,7 +1783,7 @@ export default function Page() {
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<TooltipKeybind
title="Open file"
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
@@ -1759,14 +1806,18 @@ export default function Page() {
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
fallback={
<div class="px-6 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onLineComment={addCommentToContext}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@@ -1809,6 +1860,7 @@ export default function Page() {
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let focusToken = 0
const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => {
@@ -1855,7 +1907,6 @@ export default function Page() {
})
let wrap: HTMLDivElement | undefined
let textarea: HTMLTextAreaElement | undefined
const fileComments = createMemo(() => {
const p = path()
@@ -1871,6 +1922,8 @@ export default function Page() {
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const empty = {} as Record<string, number>
const commentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
@@ -1904,12 +1957,22 @@ export default function Page() {
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
const equal = (a: Record<string, number>, b: Record<string, number>) => {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (a[key] !== b[key]) return false
}
return true
}
const updateComments = () => {
const el = wrap
const root = getRoot()
if (!el || !root) {
setPositions({})
setDraftTop(undefined)
setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
setDraftTop((prev) => (prev === undefined ? prev : undefined))
return
}
@@ -1920,7 +1983,7 @@ export default function Page() {
next[comment.id] = markerTop(el, marker)
}
setPositions(next)
setPositions((prev) => (equal(prev, next) ? prev : next))
const range = commenting()
if (!range) {
@@ -1934,11 +1997,18 @@ export default function Page() {
return
}
setDraftTop(markerTop(el, marker))
const nextTop = markerTop(el, marker)
setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
}
let commentFrame: number | undefined
const scheduleComments = () => {
requestAnimationFrame(updateComments)
if (commentFrame !== undefined) return
commentFrame = requestAnimationFrame(() => {
commentFrame = undefined
updateComments()
})
}
createEffect(() => {
@@ -1955,7 +2025,63 @@ export default function Page() {
const range = commenting()
if (!range) return
setDraft("")
requestAnimationFrame(() => textarea?.focus())
})
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeTab() !== tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
focusToken++
const token = focusToken
setOpenedComment(target.id)
setCommenting(null)
file.setSelectedLines(p, target.selection)
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const root = scroll
if (!root) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
const ready =
anchor instanceof HTMLElement &&
anchor.style.pointerEvents !== "none" &&
anchor.style.opacity !== "0"
const shadow = getRoot()
const marker = shadow ? findMarker(shadow, target.selection) : undefined
const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
if (!node) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const rootRect = root.getBoundingClientRect()
const targetRect = node.getBoundingClientRect()
const offset = targetRect.top - rootRect.top
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
root.scrollTop = Math.max(0, next)
if (ready || marker) return
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
requestAnimationFrame(() => comments.clearFocus())
})
const renderCode = (source: string, wrapperClass: string) => (
@@ -2000,105 +2126,58 @@ export default function Page() {
/>
<For each={fileComments()}>
{(comment) => (
<div
class="absolute right-6 z-30"
style={{
top: `${positions()[comment.id] ?? 0}px`,
opacity: positions()[comment.id] === undefined ? 0 : 1,
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
<LineCommentView
id={comment.id}
top={positions()[comment.id]}
open={openedComment() === comment.id}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
>
<button
type="button"
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setOpenedComment((current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
>
<Icon name="speech-bubble" size="small" />
</button>
<Show when={openedComment() === comment.id}>
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
<div class="flex flex-col gap-1.5">
<div class="text-12-medium text-text-strong whitespace-nowrap">
{getFilename(comment.file)}:{commentLabel(comment.selection)}
</div>
<div class="text-12-regular text-text-base whitespace-pre-wrap">
{comment.comment}
</div>
</div>
</div>
</Show>
</div>
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setOpenedComment((current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
comment={comment.comment}
selection={commentLabel(comment.selection)}
/>
)}
</For>
<Show when={commenting()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
<button
type="button"
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
onClick={() => textarea?.focus()}
>
<Icon name="speech-bubble" size="small" />
</button>
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
<div class="flex flex-col gap-2">
<div class="text-12-medium text-text-strong">
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
</div>
<textarea
ref={textarea}
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
rows={3}
placeholder="Add a comment"
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return
if (e.shiftKey) return
e.preventDefault()
const value = draft().trim()
if (!value) return
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value })
setCommenting(null)
}}
/>
<div class="flex justify-end gap-2">
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
Cancel
</Button>
<Button
size="small"
variant="secondary"
disabled={draft().trim().length === 0}
onClick={() => {
const value = draft().trim()
if (!value) return
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value })
setCommenting(null)
}}
>
Comment
</Button>
</div>
</div>
</div>
</div>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={commentLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
const p = path()
if (!p) return
addCommentToContext({
file: p,
selection: range(),
comment,
origin: "file",
})
setCommenting(null)
}}
onPopoverFocusOut={(e) => {
const target = e.relatedTarget as Node | null
if (target && e.currentTarget.contains(target)) return
// Delay to allow click handlers to fire first
setTimeout(() => {
if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
setCommenting(null)
}
}, 0)
}}
/>
</Show>
)}
</Show>
@@ -2228,6 +2307,7 @@ export default function Page() {
)
onCleanup(() => {
if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
@@ -2384,66 +2464,23 @@ export default function Page() {
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={terminal.all()}>
{(pty) => {
const [dismissed, setDismissed] = createSignal(false)
return (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
{(pty) => (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal
pty={pty}
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
onConnect={() => {
terminal.update({ id: pty.id, error: false })
setDismissed(false)
}}
onConnectError={() => {
setDismissed(false)
terminal.update({ id: pty.id, error: true })
}}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(pty.id)}
/>
<Show when={pty.error && !dismissed()}>
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
>
<Icon
name="circle-ban-sign"
class="w-8 h-8"
style={{ color: "rgba(239, 68, 68, 0.8)" }}
/>
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
<div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
{language.t("terminal.connectionLost.description")}
</div>
</div>
<button
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
style={{
"background-color": "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.2)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
}
onClick={() => setDismissed(true)}
>
{language.t("common.dismiss")}
</button>
</div>
</Show>
</div>
)
}}
</Show>
</div>
)}
</For>
</div>
</div>
+8 -1
View File
@@ -106,5 +106,12 @@ export function soundSrc(id: string | undefined) {
export function playSound(src: string | undefined) {
if (typeof Audio === "undefined") return
if (!src) return
void new Audio(src).play().catch(() => undefined)
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}
+58
View File
@@ -0,0 +1,58 @@
const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
type State =
| {
status: "pending"
}
| {
status: "ready"
}
| {
status: "failed"
message: string
}
const state = new Map<string, State>()
const waiters = new Map<string, Array<(state: State) => void>>()
export const Worktree = {
get(directory: string) {
return state.get(normalize(directory))
},
pending(directory: string) {
const key = normalize(directory)
const current = state.get(key)
if (current && current.status !== "pending") return
state.set(key, { status: "pending" })
},
ready(directory: string) {
const key = normalize(directory)
state.set(key, { status: "ready" })
const list = waiters.get(key)
if (!list) return
waiters.delete(key)
for (const fn of list) fn({ status: "ready" })
},
failed(directory: string, message: string) {
const key = normalize(directory)
state.set(key, { status: "failed", message })
const list = waiters.get(key)
if (!list) return
waiters.delete(key)
for (const fn of list) fn({ status: "failed", message })
},
wait(directory: string) {
const key = normalize(directory)
const current = state.get(key)
if (current && current.status !== "pending") return Promise.resolve(current)
return new Promise<State>((resolve) => {
const list = waiters.get(key)
if (!list) {
waiters.set(key, [resolve])
return
}
list.push(resolve)
})
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {
@@ -170,22 +170,18 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
</Switch>
</li>
<Show when={!props.hideGetStarted}>
{" "}
<li>
{" "}
<A href="/download" data-slot="cta-button">
{" "}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
{" "}
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>{" "}
</svg>{" "}
Free{" "}
</A>{" "}
/>
</svg>
Free
</A>
</li>
</Show>
</ul>
@@ -106,10 +106,13 @@
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 55rem) {
display: none;
@@ -97,7 +97,7 @@ export default function Changelog() {
<Meta name="description" content="OpenCode release notes and changelog" />
<div data-component="container">
<Header hideGetStarted />
<Header />
<div data-component="content">
<section data-component="changelog-hero">
@@ -141,8 +141,6 @@ export async function POST(input: APIEvent) {
return couponID
})()
// get user
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
@@ -422,8 +420,8 @@ export async function POST(input: APIEvent) {
}
if (body.type === "invoice.payment_succeeded") {
if (
body.data.object.billing_reason === "subscription_cycle" ||
body.data.object.billing_reason === "subscription_create"
body.data.object.billing_reason === "subscription_create" ||
body.data.object.billing_reason === "subscription_cycle"
) {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
@@ -476,6 +474,70 @@ export async function POST(input: APIEvent) {
},
}),
)
} else if (body.data.object.billing_reason === "manual") {
const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const invoiceID = body.data.object.id as string
const customerID = body.data.object.customer as string
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found")
await Actor.provide("system", { workspaceID }, async () => {
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
invoiceID,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
customerID,
})
})
})
}
}
if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") {
if (body.data.object.billing_reason === "manual") {
const workspaceID = body.data.object.metadata?.workspaceID
const invoiceID = body.data.object.id
if (!workspaceID) throw new Error("Workspace ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID)
console.log(JSON.stringify(paymentIntent))
const errorMessage =
typeof paymentIntent === "object" && paymentIntent !== null
? paymentIntent.last_payment_error?.message
: undefined
await Actor.provide("system", { workspaceID }, async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
reloadError: errorMessage ?? "Payment failed.",
timeReloadError: sql`now()`,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
})
}
}
if (body.type === "charge.refunded") {
@@ -59,4 +59,84 @@
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="setting-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -2,7 +2,7 @@ import { action, useParams, useAction, useSubmission, json, query, createAsync }
import { createStore } from "solid-js/store"
import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { Database, eq, and, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Black } from "@opencode-ai/console-core/black.js"
@@ -32,6 +32,7 @@ const querySubscription = query(async (workspaceID: string) => {
return {
plan: row.subscription.plan,
useBalance: row.subscription.useBalance ?? false,
rollingUsage: Black.analyzeRollingUsage({
plan: row.subscription.plan,
usage: row.rollingUsage ?? 0,
@@ -107,6 +108,30 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
)
}, "sessionUrl")
const setUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const useBalance = form.get("useBalance")?.toString() === "true"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscription: useBalance
? sql`JSON_SET(subscription, '$.useBalance', true)`
: sql`JSON_REMOVE(subscription, '$.useBalance')`,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "setUseBalance")
export function BlackSection() {
const params = useParams()
const billing = createAsync(() => queryBillingInfo(params.id!))
@@ -117,6 +142,7 @@ export function BlackSection() {
const cancelSubmission = useSubmission(cancelWaitlist)
const enrollAction = useAction(enroll)
const enrollSubmission = useSubmission(enroll)
const useBalanceSubmission = useSubmission(setUseBalance)
const [store, setStore] = createStore({
sessionRedirecting: false,
cancelled: false,
@@ -185,6 +211,20 @@ export function BlackSection() {
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
</div>
</div>
<form action={setUseBalance} method="post" data-slot="setting-row">
<p>Use your available balance after reaching the usage limits</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().useBalance}
disabled={useBalanceSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</section>
)}
</Show>
@@ -184,7 +184,7 @@ export function ReloadSection() {
</div>
</form>
</Show>
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
<Show when={billingInfo()?.reloadError}>
<div data-slot="section-content">
<div data-slot="reload-error">
<p>
@@ -43,8 +43,9 @@ export default function () {
</span>
<Show when={userInfo()?.isAdmin}>
<span data-slot="billing-info">
<Switch>
<Match when={!billingInfo()?.customerID}>
<Show
when={billingInfo()?.customerID}
fallback={
<button
data-color="primary"
data-size="sm"
@@ -53,13 +54,12 @@ export default function () {
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
</button>
</Match>
<Match when={!billingInfo()?.subscriptionID}>
<span data-slot="balance">
Current balance <b>${balance()}</b>
</span>
</Match>
</Switch>
}
>
<span data-slot="balance">
Current balance <b>${balance()}</b>
</span>
</Show>
</span>
</Show>
</p>
@@ -110,6 +110,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
reloadError: billing.reloadError,
timeReloadError: billing.timeReloadError,
subscription: billing.subscription,
subscriptionID: billing.subscriptionID,
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
@@ -84,6 +84,7 @@ export async function handler(
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const billingSource = validateBilling(authInfo, modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@@ -96,7 +97,6 @@ export async function handler(
retry,
stickyProvider,
)
validateBilling(authInfo, modelInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
@@ -183,7 +183,7 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo)
await rateLimiter?.track()
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
await reload(authInfo, costInfo)
return new Response(body, {
status: resStatus,
@@ -219,7 +219,7 @@ export async function handler(
if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
await reload(authInfo, costInfo)
}
c.close()
@@ -484,54 +484,58 @@ export async function handler(
}
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
if (!authInfo) return
if (authInfo.provider?.credentials) return
if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return
if (!authInfo) return "anonymous"
if (authInfo.provider?.credentials) return "free"
if (authInfo.isFree) return "free"
if (modelInfo.allowAnonymous) return "free"
// Validate subscription billing
if (authInfo.billing.subscription && authInfo.subscription) {
const sub = authInfo.subscription
const plan = authInfo.billing.subscription.plan
try {
const sub = authInfo.subscription
const plan = authInfo.billing.subscription.plan
const formatRetryTime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
const formatRetryTime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
const result = Black.analyzeWeeklyUsage({
plan,
usage: sub.fixedUsage,
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
// Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) {
const result = Black.analyzeRollingUsage({
plan,
usage: sub.rollingUsage,
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
return "subscription"
} catch (e) {
if (!authInfo.billing.subscription.useBalance) throw e
}
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
const result = Black.analyzeWeeklyUsage({
plan,
usage: sub.fixedUsage,
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
// Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) {
const result = Black.analyzeRollingUsage({
plan,
usage: sub.rollingUsage,
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
}
return
}
// Validate pay as you go billing
@@ -571,6 +575,8 @@ export async function handler(
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
return "balance"
}
function validateModelSettings(authInfo: AuthInfo) {
@@ -587,6 +593,7 @@ export async function handler(
authInfo: AuthInfo,
modelInfo: ModelInfo,
providerInfo: ProviderInfo,
billingSource: ReturnType<typeof validateBilling>,
usageInfo: UsageInfo,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
@@ -643,7 +650,8 @@ export async function handler(
"cost.total": Math.round(totalCostInCent),
})
if (!authInfo) return
if (billingSource === "anonymous") return
authInfo = authInfo!
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.use((db) =>
@@ -661,13 +669,13 @@ export async function handler(
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(authInfo.subscription
...(billingSource === "subscription"
? (() => {
const plan = authInfo.billing.subscription!.plan
const black = BlackData.getLimits({ plan })
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.34",
"version": "1.1.36",
"private": true,
"type": "module",
"license": "MIT",
+6 -25
View File
@@ -78,8 +78,6 @@ export namespace Billing {
const customerID = billing.customerID
const paymentMethodID = billing.paymentMethodID
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
const paymentID = Identifier.create("payment")
let invoice
try {
const draft = await Billing.stripe().invoices.create({
customer: customerID!,
@@ -87,6 +85,10 @@ export namespace Billing {
default_payment_method: paymentMethodID!,
collection_method: "charge_automatically",
currency: "usd",
metadata: {
workspaceID: Actor.workspace(),
amount: amountInCents.toString(),
},
})
await Billing.stripe().invoiceItems.create({
amount: amountInCents,
@@ -103,19 +105,17 @@ export namespace Billing {
description: ITEM_FEE_NAME,
})
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
invoice = await Billing.stripe().invoices.pay(draft.id!, {
await Billing.stripe().invoices.pay(draft.id!, {
off_session: true,
payment_method: paymentMethodID!,
expand: ["payments"],
})
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
throw new Error(invoice.last_finalization_error?.message)
} catch (e: any) {
console.error(e)
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
reloadError: e.message ?? "Payment failed.",
timeReloadError: sql`now()`,
})
@@ -123,25 +123,6 @@ export namespace Billing {
)
return
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: paymentID,
amount: centsToMicroCents(amountInCents),
invoiceID: invoice.id!,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
customerID,
})
})
}
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
@@ -24,9 +24,10 @@ export const BillingTable = mysqlTable(
timeReloadLockedTill: utc("time_reload_locked_till"),
subscription: json("subscription").$type<{
status: "subscribed"
coupon?: string
seats: number
plan: "20" | "100" | "200"
useBalance?: boolean
coupon?: string
}>(),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.34",
"version": "1.1.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.34",
"version": "1.1.36",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.34",
"version": "1.1.36",
"type": "module",
"license": "MIT",
"scripts": {
-1
View File
@@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1"
comrak = { version = "0.50", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
+1 -1
View File
@@ -525,4 +525,4 @@ async fn spawn_local_server(
break Ok(child);
}
}
}
}
+39 -6
View File
@@ -370,18 +370,51 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
}),
)
const errorMessage = () => {
const error = serverData.error
if (!error) return "Unknown error"
if (typeof error === "string") return error
if (error instanceof Error) return error.message
return String(error)
}
const restartApp = async () => {
await invoke("kill_sidecar").catch(() => undefined)
await relaunch().catch(() => undefined)
}
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={serverData.state !== "pending" && serverData()}
when={serverData.state === "errored"}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
{(data) => props.children(data)}
</Show>
}
>
{(data) => props.children(data)}
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
<div class="text-16-semibold">OpenCode failed to start</div>
<div class="text-12-regular opacity-70 text-center max-w-xl">
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
and try again.
</div>
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
</div>
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
Restart App
</button>
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
</Show>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.34",
"version": "1.1.36",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.34"
version = "1.1.36"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.34",
"version": "1.1.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1
View File
@@ -2,3 +2,4 @@ research
dist
gen
app.log
src/provider/models-snapshot.ts
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.34",
"version": "1.1.36",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.2.0",
"@gitlab/gitlab-ai-provider": "3.3.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
+10
View File
@@ -15,6 +15,16 @@ process.chdir(dir)
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`https://models.dev/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
)
console.log("Generated models-snapshot.ts")
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
@@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
type PermissionStage = "permission" | "always" | "reject"
@@ -304,8 +305,11 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
const textareaKeybindings = useTextareaKeybindings()
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
props.onCancel()
@@ -384,8 +388,11 @@ function Prompt<const T extends Record<string, string>>(props: {
})
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
@@ -3,7 +3,7 @@ import { createMemo, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { tint, useTheme } from "../../context/theme"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
@@ -272,7 +272,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(index())}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
<text
fg={
isActive()
? selectedForeground(theme, theme.accent)
: isAnswered()
? theme.text
: theme.textMuted
}
>
{q.header}
</text>
</box>
@@ -285,7 +293,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
</box>
</box>
</Show>
@@ -304,7 +312,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
<box
onMouseOver={() => moveTo(i())}
onMouseDown={() => moveTo(i())}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
@@ -329,7 +341,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
</For>
<Show when={custom()}>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box
onMouseOver={() => moveTo(options().length)}
onMouseDown={() => moveTo(options().length)}
onMouseUp={() => selectOption()}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
@@ -358,6 +374,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
initialValue={input()}
placeholder="Type your own answer"
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
@@ -144,12 +144,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
moveTo(next)
moveTo(next, true)
}
function moveTo(next: number, center = false) {
setStore("selected", next)
props.onMove?.(selected()!)
const option = selected()
if (option) props.onMove?.(option)
if (!scroll) return
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)
+1
View File
@@ -46,6 +46,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
function number(key: string) {
const value = process.env[key]
-4
View File
@@ -22,10 +22,6 @@ export namespace Global {
cache,
config,
state,
// Allow overriding models.dev URL for offline deployments
get modelsDevUrl() {
return process.env.OPENCODE_MODELS_URL || "https://models.dev"
},
}
}
+7 -1
View File
@@ -355,7 +355,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
if (auth.type !== "oauth") return {}
// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
const allowedModels = new Set([
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1-codex",
])
for (const modelId of Object.keys(provider.models)) {
if (!allowedModels.has(modelId)) {
delete provider.models[modelId]
+16
View File
@@ -338,6 +338,22 @@ export namespace Project {
return valid
}
export async function addSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? []
if (!sandboxes.includes(directory)) sandboxes.push(directory)
draft.sandboxes = sandboxes
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
export async function removeSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? []
@@ -1,14 +0,0 @@
import { Global } from "../global"
export async function data() {
const path = Bun.env.MODELS_DEV_API_JSON
if (path) {
const file = Bun.file(path)
if (await file.exists()) {
return await file.text()
}
}
const url = Global.Path.modelsDevUrl
const json = await fetch(`${url}/api.json`).then((x) => x.text())
return json
}
+38 -16
View File
@@ -2,9 +2,13 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import { data } from "./models-macro" with { type: "macro" }
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { lazy } from "@/util/lazy"
// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
/* @ts-ignore */
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
@@ -76,28 +80,35 @@ export namespace ModelsDev {
export type Provider = z.infer<typeof Provider>
export async function get() {
refresh()
function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
}
export const Data = lazy(async () => {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) return result as Record<string, Provider>
if (typeof data === "function") {
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
const url = Global.Path.modelsDevUrl
const json = await fetch(`${url}/api.json`).then((x) => x.text())
return JSON.parse(json) as Record<string, Provider>
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
.then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined)
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
return JSON.parse(json)
})
export async function get() {
const result = await Data()
return result as Record<string, Provider>
}
export async function refresh() {
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
const file = Bun.file(filepath)
log.info("refreshing", {
file,
})
const url = Global.Path.modelsDevUrl
const result = await fetch(`${url}/api.json`, {
const result = await fetch(`${url()}/api.json`, {
headers: {
"User-Agent": Installation.USER_AGENT,
},
@@ -107,8 +118,19 @@ export namespace ModelsDev {
error: e,
})
})
if (result && result.ok) await Bun.write(file, await result.text())
if (result && result.ok) {
await Bun.write(file, await result.text())
ModelsDev.Data.reset()
}
}
}
setInterval(() => ModelsDev.refresh(), 60 * 1000 * 60).unref()
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
ModelsDev.refresh()
setInterval(
async () => {
await ModelsDev.refresh()
},
60 * 1000 * 60,
).unref()
}
+9 -10
View File
@@ -1,5 +1,5 @@
import type { APICallError, ModelMessage } from "ai"
import { unique } from "remeda"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
@@ -26,6 +26,7 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
return "bedrock"
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return "anthropic"
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
@@ -186,18 +187,12 @@ export namespace ProviderTransform {
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
lastContent.providerOptions = {
...lastContent.providerOptions,
...providerOptions,
}
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
continue
}
}
msg.providerOptions = {
...msg.providerOptions,
...providerOptions,
}
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
}
return msgs
@@ -349,8 +344,12 @@ export namespace ProviderTransform {
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
const copilotEfforts = iife(() => {
if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return WIDELY_SUPPORTED_EFFORTS
})
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
copilotEfforts.map((effort) => [
effort,
{
reasoningEffort: effort,
+2 -2
View File
@@ -10,7 +10,7 @@ export namespace Question {
export const Option = z
.object({
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
@@ -21,7 +21,7 @@ export namespace Question {
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(30).describe("Very short label (max 30 chars)"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+1 -1
View File
@@ -598,7 +598,7 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
+1 -1
View File
@@ -40,7 +40,7 @@ export namespace SessionSummary {
.flatMap((x) => x.parts)
.filter((x) => x.type === "patch")
.flatMap((x) => x.files)
.map((x) => path.relative(Instance.worktree, x)),
.map((x) => path.relative(Instance.worktree, x).replaceAll("\\", "/")),
)
const diffs = await computeDiff({ messages: input.messages }).then((x) =>
x.filter((x) => {
+2 -1
View File
@@ -52,10 +52,11 @@ export namespace SystemPrompt {
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
export async function environment() {
export async function environment(model: Provider.Model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,

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