Compare commits

...
222 Commits
Author SHA1 Message Date
Dax Raad 2beeccb39a perf(tool): reduce edit and read memory overhead 2026-03-24 16:19:38 -04:00
David Hill 77b3b46788 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan 36dfe1646b fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot] 6926dc26d1 chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot] eb74e4a6d2 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot] 85d8e143bf chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan 8e1b53b32c fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan 0a7dfc03ee fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan 4c27e7fc64 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash 0f5626d2e4 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash 5ea95451dd fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash 9239d877b9 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot] fc68c24433 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker db9619dad6 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long 84d9b38873 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot] 8035c3435b chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian 71e7603d71 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill 40e49c5b49 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker afe9b97274 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot] 3b3549902d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill e9a9c75c1f tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David HillandLukeParkerDev 2b171828b0 tui: prevent project avatar popover flicker when switching projects (#18660)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-23 10:20:49 +10:00
Luke Parker 8dd817023a chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
Dax Raad 0d6c601365 changelog slash command 2026-03-22 19:45:23 -04:00
opencode-agent[bot] 5460bf9989 chore: generate 2026-03-22 23:32:09 +00:00
opencode eb3bfffad4 release: v1.3.0 2026-03-22 23:32:01 +00:00
Dax e2d03ce38c feat: interactive update flow for non-patch releases (#18662) 2026-03-22 23:12:40 +00:00
David Hill 32f9dc6383 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
FilipandBrendan Allan c529529f84 fix(app): terminal rename from context menu (#18263)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-03-22 04:39:51 +00:00
Kit Langton 13bac9c91a effectify Pty service (#18572) 2026-03-22 01:17:13 +00:00
Kit Langton fe53af4819 effectify ToolRegistry service (#18571) 2026-03-22 01:06:01 +00:00
opencode-agent[bot] e82c5a9a28 chore: generate 2026-03-22 00:51:19 +00:00
Kit Langton 3236f228fb effectify Plugin service (#18570) 2026-03-22 00:50:22 +00:00
Kit Langton 0e0e7a4a4b effectify Command service (#18568) 2026-03-22 00:21:41 +00:00
Kit Langton 10a3d6c54e effectify SessionStatus service (#18565) 2026-03-21 23:55:07 +00:00
opencode-agent[bot] 832b8e252e chore: update nix node_modules hashes 2026-03-21 19:17:12 +00:00
Sebastian 040f551c57 Upgrade opentui to 0.1.88 (#18079) 2026-03-21 19:00:42 +00:00
Protocol Zero cc818f8032 fix(provider): only set thinkingConfig for models with reasoning capability (#18283) 2026-03-21 11:57:52 -05:00
opencode-agent[bot] d5337b41f4 chore: update nix node_modules hashes 2026-03-21 15:47:15 +00:00
opencode-agent[bot] 9f7a76d6c0 chore: generate 2026-03-21 15:34:05 +00:00
Brendan Allan 6a16db4b92 app: manage mutation loading states with tanstack query (#18501) 2026-03-21 23:33:04 +08:00
Brendan Allan 9ad6588f3e app: allow navigating projects with keybinds (#18502) 2026-03-21 22:13:09 +08:00
opencode-agent[bot] fb6bf0b35e chore: generate 2026-03-21 13:12:54 +00:00
Dax Raad f80343b875 fix annotation 2026-03-21 09:11:15 -04:00
Frank 9b805e1cc4 wip: zen 2026-03-21 04:07:51 -04:00
opencode-agent[bot] 2e0d5d2308 chore: generate 2026-03-21 04:52:23 +00:00
Kit Langton 38e0dc9ccd Move service state into InstanceState, flatten service facades (#18483) 2026-03-21 04:51:35 +00:00
opencode-agent[bot] 40aeaa120d chore: generate 2026-03-21 03:11:28 +00:00
Kit Langton 6a64177589 fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) 2026-03-20 23:10:34 -04:00
Dax Raad 5dc47905a9 allow customizing DB location 2026-03-20 22:49:55 -04:00
Aiden Cline dc0044882c ignore: add danieljoshuanazareth to disavow list (#18476) 2026-03-20 21:34:07 -04:00
github-actions[bot] 45ae7dc653 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628
2026-03-21 01:28:40 +00:00
Dax Raad 129fe1e350 ci 2026-03-20 17:00:05 -04:00
Kit Langton 214a6c6cf1 fix: switch consumers to service imports to break bundle cycles (#18438) 2026-03-20 20:55:46 +00:00
Dax Raad 3f249aba6d commit and push 2026-03-20 16:36:19 -04:00
Dax Raad 5c6ec1caac fix question cross out 2026-03-20 15:50:04 -04:00
Kit Langton 24f9df5463 fix: update stale account url/email on re-login (#18426) 2026-03-20 14:50:01 -04:00
opencode-agent[bot] 12b8e1c2be chore: generate 2026-03-20 18:38:08 +00:00
Kit Langton d70099b059 fix: apply Layer.fresh at instance service definition site (#18418) 2026-03-20 14:37:12 -04:00
opencode-agent[bot] ce845a0b1b chore: update nix node_modules hashes 2026-03-20 18:16:17 +00:00
Vladimir Glafirov 05d3e65f76 feat: enable GitLab Agent Platform with workflow model discovery (#18014) 2026-03-20 12:55:22 -05:00
opencode-agent[bot] 51618e9cef chore: generate 2026-03-20 16:11:21 +00:00
Kit Langton e78944e9a4 effectify Installation service, drop Effect suffix from namespaces (#18266) 2026-03-20 12:10:33 -04:00
Aiden Cline bfdc38e421 tweak: adjust codex plugin logic so that codex instruction isn't always added (oauth plan no longer enforces instruction whitelisting) (#18337) 2026-03-20 10:37:47 -05:00
MCandAiden Cline 83023e4f0f docs: add Cloudflare Workers AI provider (#18322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-20 10:10:22 -05:00
Brendan Allan d0a57305ef app: file type filter on desktop + multiple files (#18403) 2026-03-20 15:02:07 +00:00
Shoubhit Dash 27a70ad70f fix(app): align review file comments with diff comments (#18406) 2026-03-20 14:44:18 +00:00
Luke Parker 0bbf26a1ce deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot] 83cdb4de64 chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan 4989632245 patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker d460614cd7 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke Parker 7866dbcfcc fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot] e71a21e0a8 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax 1071aca91f fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
JaaneekandJaaneek b3d0446d13 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
opencode-agent[bot] 949191ab74 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax 92cd908fb5 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Dax 6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax 52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax 37b8662a9d refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax ddcb32ae0b refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank 2c056c90da doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax 812d1bb32a chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9a58c43ef4 go: upi translation 2026-03-19 18:54:32 -04:00
Dax 63585db6a7 refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank bd44489ada go: upi payment 2026-03-19 18:44:24 -04:00
opencode-agent[bot] a6ef9e9206 chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton 6e09a1d904 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot] 4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
2dbcd79fd2 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
James Long 48a7f0fd93 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long d69962b0f7 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] a6f23cb08e chore: generate 2026-03-19 17:52:50 +00:00
James Long 0540751897 fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
opencode-agent[bot] baa204193c chore: generate 2026-03-19 16:16:29 +00:00
Aiden Cline aeece6166b ignore: revert 3 commits that broke dev branch (#18260) 2026-03-19 11:15:07 -05:00
Kit Langton 0d7e62a532 fix forked prompt attachments losing file parts (#17815) 2026-03-19 12:00:32 -04:00
Shoubhit Dash 41aa254db4 fix(app): show review on the empty session route (#18251) 2026-03-19 19:51:32 +05:30
opencode-agent[bot] d178d8249f chore: generate 2026-03-19 14:00:32 +00:00
Shoubhit Dash e6f5214779 feat: add git-backed session review modes (#17961) 2026-03-19 19:29:29 +05:30
Brendan AllanandShoubhit Dash 84f60d97a0 app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 19:29:14 +05:30
Brendan Allan cbf4b68fee electron: lazily read updater enabled 2026-03-19 21:47:47 +08:00
OpeOginniandBrendan Allan bd4527b4f2 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew MaguireandShoubhit Dash f4a9fe29a3 fix(app): ignore repeated Enter submits in prompt input (#18148)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 11:53:44 +00:00
opencode-agent[bot] 5a0bfa7061 chore: generate 2026-03-19 04:46:39 +00:00
Dax 1ac1a0287c anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline 8e09e8c612 feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
Kit Langton 84e62fc662 fix(session): preserve tagged error messages (#18165) 2026-03-18 20:36:53 -04:00
Frank a7ea93528b zen: add mimo pro/omni models 2026-03-18 20:28:41 -04:00
opencode-agent[bot] d90e3a2833 chore: update nix node_modules hashes 2026-03-19 00:08:27 +00:00
opencode-agent[bot] 1c74c2741a chore: update nix node_modules hashes 2026-03-19 00:07:30 +00:00
Luke Parker 5d2f8d77f9 fix: restore recent test regressions and upgrade effect beta (#18158) 2026-03-19 09:54:01 +10:00
Kit Langton 81be544981 feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138) 2026-03-18 19:52:43 -04:00
opencode-agent[bot] 773c1192dc chore: generate 2026-03-18 23:45:03 +00:00
Kit Langton 5ddfe4ada5 chore: type Provider.list() as Record<ProviderID, Info>, delete dead code (#18123) 2026-03-18 19:43:12 -04:00
opencode-agent[bot] a93d98bd94 chore: update nix node_modules hashes 2026-03-18 23:03:55 +00:00
Luke Parker 54ed87d53c fix(windows): use cross-spawn for shim-backed commands (#18010) 2026-03-19 08:49:16 +10:00
Aiden Cline 8ee939c741 tweak: remove unnecessary parts from the fallback system prompt (#18140) 2026-03-18 17:27:47 -05:00
Frank 1b0096bf61 docs: update go models 2026-03-18 17:48:39 -04:00
Frank 8006c29db3 fix: docs 2026-03-18 16:30:33 -04:00
Frank 3f1c96a0bb zen: minimax m2.7 2026-03-18 14:30:55 -04:00
Frank 3558deba4a zen: minimax m2.7 2026-03-18 14:15:00 -04:00
opencode-agent[bot] c3ddc85cca chore: generate 2026-03-18 17:37:40 +00:00
Kit Langton a800583aea refactor(effect): unify service namespaces and align naming (#18093) 2026-03-18 13:34:36 -04:00
Aiden Cline 171e69c2fc feat: integrate support for multi step auth flows for providers that require additional questions (#18035) 2026-03-18 11:36:19 -05:00
Aiden Cline 822bb7b336 tweak: update gpt subscription model list (#18101) 2026-03-18 10:51:39 -05:00
Frank 47cf267c23 zen: fix routing non OC traffic 2026-03-18 10:59:56 -04:00
OpeOginni 976aae7e42 fix(desktop): fix error handling by adding errorName function to identify NotFoundError rather than statusCode (#17591) 2026-03-18 19:27:56 +05:30
David Hill 0ca51eebcf tweak(ui): theme overrides (#17958) 2026-03-18 19:26:32 +05:30
David Hill 3256886e25 tui: make the title bar search easier to scan without a redundant icon 2026-03-18 13:52:57 +00:00
David Hill d2194f6dde Revert "tui: clean up search button in session header by removing magnifying glass icon and excess padding"
This reverts commit bfd4787fcd.
2026-03-18 13:52:36 +00:00
David Hill bfd4787fcd tui: clean up search button in session header by removing magnifying glass icon and excess padding 2026-03-18 13:49:58 +00:00
opencode-agent[bot] 58dce0148a chore: generate 2026-03-18 12:58:46 +00:00
OpeOginni 79635b8b41 docs(cli): update experimental TY LSP flag description for clarity across multiple languages (#14770) 2026-03-18 18:27:42 +05:30
Brendan Allan 331dacf9db app: remove debug text 2026-03-18 17:02:23 +08:00
Brendan Allan 4ba7d3b406 app: replace autoselect effects with single resource 2026-03-18 17:01:38 +08:00
Brendan Allan a43783a6d4 app: initialize command catalog more efficiently
cuts down load times by like 30-50%
2026-03-18 14:46:53 +08:00
Frank 37c5295111 zen: gpt 5.4 mini and nano 2026-03-18 02:12:30 -04:00
Johannes LoherandAiden Cline 56102ff642 fix(core): detect vLLM context overflow errors (#17763)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-17 23:52:16 -05:00
Frank 1b86c27fb8 wip: zen 2026-03-17 23:31:14 -04:00
Frank fe43bdb699 wip: zen 2026-03-17 22:50:54 -04:00
a849a17e93 feat(enterprise): contact form now pushes to salesforce 🙄 (#17964)
Co-authored-by: slickstef11 <stefan@wundergraph.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-03-17 22:43:43 -04:00
opencode-agent[bot] 0292f1b559 chore: generate 2026-03-18 02:01:02 +00:00
Kit Langton 5dfe86dcb1 refactor(truncation): effectify TruncateService, delete Scheduler (#17957) 2026-03-17 21:59:54 -04:00
Ariane Emory 4b4dd2b882 fix: Add apply_patch to EDIT_TOOLS filter (#18009) 2026-03-17 20:11:42 -05:00
opencode-agent[bot] bc949af623 chore: generate 2026-03-18 01:05:16 +00:00
Kit Langton 9e7c136de7 refactor(snapshot): effectify SnapshotService (#17878) 2026-03-17 21:04:16 -04:00
Kit Langton fee3c196c5 add prompt schema validation debug logs (#17812) 2026-03-17 19:18:16 -04:00
Frank 6c047391bb wip: zen 2026-03-17 19:06:22 -04:00
Frank 350df0b261 zen: add missing model lab names 2026-03-17 18:41:38 -04:00
Frank fbabc97c4c zen: error logging 2026-03-17 16:53:10 -04:00
David HillandShoubhit Dash 7daea69e13 tweak(ui): add an empty state to the sidebar when no projects (#17971)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-17 19:54:14 +00:00
Frank 0772a95918 wip: zen 2026-03-17 15:00:44 -04:00
Frank dadddc9c8c zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni 6708c3f6cf docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash ba22976568 fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
Brendan Allan 0afeaea21f app: inherit owner when creating prompt session 2026-03-17 19:47:06 +08:00
opencode-agent[bot] b07b5a9b7f chore: generate 2026-03-17 11:15:35 +00:00
Luke Parker dbbe931a18 fix(app): avoid prompt tooltip Switch on startup (#17857) 2026-03-17 06:14:30 -05:00
opencode-agent[bot] e14e874e51 chore: generate 2026-03-17 03:47:33 +00:00
Kyle AltendorfandAiden Cline 544315dff7 docs: add describe annotation to snapshot config field (#17861)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-16 22:46:09 -05:00
Aiden Cline f13da808ff chore: denounce ai spammer (#17901) 2026-03-16 22:38:15 -05:00
Luke Parker e416e59ea6 test(app): deflake slash terminal toggle flow (#17881) 2026-03-17 12:55:58 +10:00
Luke Parker cb69501098 test(opencode): deflake file and tool timing (#17859) 2026-03-17 00:49:04 +00:00
Kyle Altendorf a64f604d54 fix(tui): check for selected text instead of any selection in dialog escape handler (#16779) 2026-03-17 10:25:03 +10:00
opencode-agent[bot] d7093abf61 chore: update nix node_modules hashes 2026-03-17 00:05:19 +00:00
opencode-agent[bot] 60af447908 chore: update nix node_modules hashes 2026-03-16 23:54:30 +00:00
opencode-agent[bot] 1cdc558ac0 chore: generate 2026-03-16 23:52:10 +00:00
Kit Langton 3849822769 refactor(skill): effectify SkillService as scoped service (#17849) 2026-03-16 23:51:07 +00:00
AbigailJixiangyuyu e9a17e4480 fix(windows): restore /editor support on Windows (#17146) 2026-03-17 08:11:02 +10:00
Aiden Cline 68809365df fix: github copilot enterprise integration (#17847) 2026-03-16 17:05:14 -05:00
opencode-agent[bot] 8da511dfa8 chore: generate 2026-03-16 20:19:50 +00:00
Kit Langton 69381f6aea refactor(file): effectify FileService as scoped service (#17845) 2026-03-16 16:18:39 -04:00
opencode-agent[bot] df6508530f chore: generate 2026-03-16 19:59:49 +00:00
Kit Langton 335356280c refactor(format): effectify FormatService as scoped service (#17675) 2026-03-16 15:58:36 -04:00
opencode-agent[bot] 03d84f49c2 chore: generate 2026-03-16 18:24:21 +00:00
Kit Langton 2cbdf04ec9 refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) 2026-03-16 18:23:13 +00:00
opencode-agent[bot] 410fbd8a00 chore: generate 2026-03-16 18:00:18 +00:00
Kit Langton e5cbecf17c fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) 2026-03-16 13:59:11 -04:00
opencode-agent[bot] ca3af5dc6a chore: generate 2026-03-16 17:19:44 +00:00
Kit Langton 9e740d9947 stack: effectify-file-watcher-service (#17827) 2026-03-16 13:18:40 -04:00
opencode-agent[bot] d4694d058c chore: generate 2026-03-16 16:56:12 +00:00
Kit Langton 469c3a4204 refactor(instance): move scoped services to LayerMap (#17544) 2026-03-16 12:55:14 -04:00
DS 4cb29967f6 fix(opencode): apply message transforms during compaction (#17823) 2026-03-16 11:32:53 -05:00
Johannes Loher e718db624f fix(core): consider code: context_length_exceeded as context overflow in API call errors (#17748) 2026-03-16 10:25:24 -05:00
Michal Šlesár 15b27e0d18 fix(app): agent switch should not reset thinking level (#17470) 2026-03-16 19:43:29 +05:30
Kit Langton c523aac586 fix(cli): scope active org labels to the active account (#16957) 2026-03-16 10:01:42 -04:00
Shoubhit Dash 51fcd04a70 Wrap question option descriptions instead of truncating (#17782) 2026-03-16 11:29:18 +00:00
Luke Parker 4d7cbdcbef fix(ci): workaround by using hoisted Bun linker on Windows (#17751) 2026-03-16 08:39:06 +00:00
Luke Parker 59c530cc6c fix(opencode): teach Kit's test what an ID is (#17745) 2026-03-16 18:08:27 +10:00
Jason Quenseandjquense c2ca1494e5 fix(opencode): preserve prompt tool enables with empty agent permissions (#17064)
Co-authored-by: jquense <jquense@ramp.com>
2026-03-15 23:01:46 -05:00
opencode 4ee426ba54 release: v1.2.27 2026-03-16 02:33:48 +00:00
Aiden Cline 510374207d fix: vcs watcher if statement (#17673) 2026-03-15 19:20:39 -05:00
Erik Engervall aedbecedf7 docs: Add opencode-firecrawl to ecosystem documentation (#17672) 2026-03-15 18:56:24 -05:00
Frank 9c00669927 zen: update claude prices 2026-03-15 10:54:40 -04:00
David Hill b9f6b40e3a tweak(ui): remove open label (#17512) 2026-03-15 08:25:40 -05:00
Orlando Ascanio ad06d8f496 docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) 2026-03-15 08:22:32 -05:00
Kit Langton 2fc06c5a17 chore(permission): delete legacy permission module (#17534) 2026-03-14 20:56:52 -05:00
Kit Langton 52877d8765 fix(question): clean up pending entry on abort (#17533) 2026-03-15 00:49:49 +00:00
Sebastian 8f957b8f90 remove sighup exit (#17254) 2026-03-15 00:52:28 +01:00
opencode-agent[bot] 0befa1e57e chore: generate 2026-03-14 18:29:06 +00:00
Kit Langton f015154314 refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511) 2026-03-14 18:28:00 +00:00
Shoubhit Dash 689d9e14ea fix(app): handle multiline web paste in prompt composer (#17509) 2026-03-14 22:51:45 +05:30
Kit Langton 66e8c57ed1 refactor(schema): inline branded ID schemas (#17504) 2026-03-14 16:14:46 +00:00
opencode-agent[bot] b698f14e55 chore: generate 2026-03-14 15:59:01 +00:00
Kit Langton cec1255b36 refactor(question): effectify QuestionService (#17432) 2026-03-14 11:58:00 -04:00
Aiden Cline 88226f3061 tweak: ensure that compaction message is tracked as agent initiated (#17431) 2026-03-14 10:46:24 -05:00
James Long 8c53b2b470 fix(core): increase default chunk timeout from 2 min to 5 min (#17490) 2026-03-14 14:27:06 +00:00
Marcus Schiesser f2d3a4c70f fix(ui): prevent long filenames from overlapping actions (#17151) 2026-03-13 16:17:15 -05:00
Michael DwanandAiden Cline 4b9b86b544 fix(opencode): lost sessions across worktrees and orphan branches (#16389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-13 15:51:55 -04:00
David Hill f54abe58cf tui: update compaction status message to use Session instead of History across all languages
The compaction message now correctly indicates the current session was compacted rather than the entire history, making it clearer to users which conversation data was optimized.
2026-03-13 16:33:01 +00:00
opencode d954026dd8 release: v1.2.26 2026-03-13 16:32:53 +00:00
Adam 4ad8116ce3 fix(app): model selection persist by session (#17348) 2026-03-13 11:05:08 -05:00
David Hill 5c7088338c fix(app): polish prompt composer controls (#17388) 2026-03-13 10:48:10 -05:00
Adam 389daa03df fix(app): sidebar sync 2026-03-13 10:47:45 -05:00
David Hill 1cbe7b0854 tweak(ui): use new-session icon in sidebar buttons 2026-03-13 10:18:08 -05:00
David Hill 050d71bcf9 fix(app): avoid clipping new session during sidebar anim 2026-03-13 10:18:03 -05:00
David Hill ffde837e83 fix(app): animate titlebar controls on sidebar open 2026-03-13 10:17:56 -05:00
David Hill 536abea2e2 fix(app): restore sidebar dash and sync session spinner colors (#17384) 2026-03-13 10:08:23 -05:00
Kit Langton c7a52b6a2d feat(schema): scaffold effect-to-zod bridge (#17273) 2026-03-13 10:59:57 -04:00
Adam c4ccb50c37 fix(app): fork should copy prompt into new session (#17375) 2026-03-13 09:59:11 -05:00
Jack 5aaf1ddfb7 fix(ui): force wasm highlighter for markdown code blocks (#17373) 2026-03-13 09:57:14 -05:00
464 changed files with 16805 additions and 9180 deletions
+4
View File
@@ -10,6 +10,9 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
fwang fwang
@@ -17,6 +20,7 @@ iamdavidhill
jayair jayair
kitlangton kitlangton
kommander kommander
-opencode2026
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
+9 -1
View File
@@ -41,5 +41,13 @@ runs:
shell: bash shell: bash
- name: Install dependencies - name: Install dependencies
run: bun install run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash shell: bash
+22 -6
View File
@@ -50,20 +50,17 @@ jobs:
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
settings: settings:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows - name: windows
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env: env:
PLAYWRIGHT_BROWSERS_PATH: 0 PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -76,9 +73,28 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install Playwright browsers - name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app working-directory: packages/app
run: ${{ matrix.settings.playwright }} run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests - name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
+1 -1
View File
@@ -33,6 +33,6 @@ jobs:
with: with:
issue-id: ${{ github.event.issue.number }} issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
roles: admin,maintain roles: admin,maintain,write
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+5 -3
View File
@@ -1,4 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
package-lock.json bun.lock
.gitignore
package-lock.json
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
description: Translate content for a specified locale while preserving technical terms description: Translate content for a specified locale while preserving technical terms
mode: subagent mode: subagent
model: opencode/gemini-3-pro model: opencode/gpt-5.4
--- ---
You are a professional translator and localization specialist. You are a professional translator and localization specialist.
+5
View File
@@ -0,0 +1,5 @@
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
+10 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
...options, ...options,
@@ -24,7 +22,16 @@ interface PR {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+6 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(ASSIGNEES as [string, ...string[]])
-6
View File
@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+107 -55
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -41,12 +41,14 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "4.0.0-beta.31", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
@@ -77,7 +79,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -111,7 +113,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -128,7 +130,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
@@ -138,7 +140,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -162,7 +164,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -186,7 +188,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -219,7 +221,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -227,7 +229,7 @@
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"effect": "4.0.0-beta.31", "effect": "catalog:",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "^10", "electron-store": "^10",
"electron-updater": "^6", "electron-updater": "^6",
@@ -250,7 +252,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -279,7 +281,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -295,7 +297,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.2.25", "version": "1.3.0",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -324,8 +326,7 @@
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
@@ -337,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87", "@opentui/core": "0.1.90",
"@opentui/solid": "0.1.87", "@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -351,11 +352,13 @@
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -366,6 +369,7 @@
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
@@ -400,14 +404,15 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
@@ -416,7 +421,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -440,7 +445,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.2.25", "version": "1.3.0",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -451,7 +456,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -486,7 +491,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -532,7 +537,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -543,7 +548,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.2.25", "version": "1.3.0",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -583,6 +588,8 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
}, },
"overrides": { "overrides": {
@@ -591,6 +598,7 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.35",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -604,7 +612,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.9", "@types/bun": "1.3.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -612,9 +620,9 @@
"ai": "5.0.124", "ai": "5.0.124",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.31", "effect": "4.0.0-beta.35",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -972,6 +980,10 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
@@ -1100,10 +1112,6 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.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-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@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=="], "@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=="], "@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=="],
@@ -1168,6 +1176,8 @@
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
@@ -1438,21 +1448,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="], "@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="], "@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1880,6 +1890,8 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="], "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -1958,10 +1970,14 @@
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
@@ -2044,7 +2060,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -2052,6 +2068,8 @@
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -2438,7 +2456,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
@@ -2536,6 +2554,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@@ -2722,9 +2742,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -2738,7 +2758,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], "effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3006,6 +3026,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3014,6 +3036,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "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-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3176,6 +3200,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -3408,10 +3434,14 @@
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
@@ -3598,7 +3628,7 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
@@ -3760,6 +3790,8 @@
"opencode": ["opencode@workspace:packages/opencode"], "opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -4022,6 +4054,10 @@
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -4084,6 +4120,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -4216,7 +4254,7 @@
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
@@ -4280,6 +4318,8 @@
"stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -4986,12 +5026,18 @@
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -5022,16 +5068,14 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -5162,8 +5206,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5352,6 +5394,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -5418,6 +5462,10 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5494,6 +5542,8 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6244,6 +6294,8 @@
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+8
View File
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })
@@ -201,6 +202,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", { const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts", handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")], link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -219,6 +224,9 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_LIMITS"),
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=", "x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=", "aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=", "aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI=" "x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
} }
} }
+10 -6
View File
@@ -4,11 +4,12 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.10", "packageManager": "bun@1.3.11",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
@@ -24,7 +25,8 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@types/bun": "1.3.9", "@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -41,9 +43,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.31", "effect": "4.0.0-beta.35",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -110,6 +112,8 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
} }
+10
View File
@@ -174,6 +174,8 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- In terminal tests, type through the browser. Do not write to the PTY through the SDK. - In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. - Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state ### Wait on state
@@ -182,6 +184,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Avoid race-prone flows that assume work is finished after an action - Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers - Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state - Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks ### Add hooks
@@ -189,11 +194,16 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` - Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony - Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI - When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers ### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise - Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity - Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests ## Writing New Tests
+242 -9
View File
@@ -1,3 +1,4 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
@@ -8,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector, projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
@@ -16,11 +18,22 @@ import {
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
promptSelector,
terminalSelector, terminalSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
.evaluate(() => { .evaluate(() => {
@@ -61,6 +74,15 @@ async function terminalReady(page: Page, term?: Locator) {
}, id) }, id)
} }
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) { async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first() const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next) const id = await terminalID(next)
@@ -73,6 +95,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
) )
} }
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first() const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000 const timeout = input?.timeout ?? 10_000
@@ -81,6 +126,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
} }
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first() const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000 const timeout = input.timeout ?? 10_000
@@ -93,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true) await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
} }
export async function openPalette(page: Page) { export async function openPalette(page: Page, key = "K") {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+P`) await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@@ -125,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "isSidebarClosed")
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true" return (await button.getAttribute("aria-expanded")) !== "true"
} }
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+B`) await page.keyboard.press(`${modKey}+B`)
@@ -138,7 +260,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) { export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "openSidebar")
await button.click() await button.click()
const opened = await expect(button) const opened = await expect(button)
@@ -155,7 +277,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "closeSidebar")
await button.click() await button.click()
const closed = await expect(button) const closed = await expect(button)
@@ -170,6 +292,7 @@ export async function closeSidebar(page: Page) {
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page) await defocus(page)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -182,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click() await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
return dialog return dialog
@@ -243,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() { export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n") await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -268,12 +395,24 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
} }
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) { export async function waitSlug(page: Page, skip: string[] = []) {
let prev = "" let prev = ""
let next = "" let next = ""
await expect await expect
.poll( .poll(
() => { async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
if (skip.includes(slug)) return "" if (skip.includes(slug)) return ""
@@ -291,6 +430,94 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -702,8 +929,14 @@ export async function openStatusPopover(page: Page) {
} }
export async function openProjectMenu(page: Page, projectSlug: string) { export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -712,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger const clicked = await trigger
.click({ timeout: 1500 }) .click({ force: true, timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
+3
View File
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => { test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/") await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
}) })
+10 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openPalette } from "../actions" import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => { test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
}) })
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
+45 -5
View File
@@ -1,7 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import {
import { promptSelector } from "./selectors" healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [ directory: [
async ({}, use) => { async ({}, use) => {
const directory = await getWorktree() const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID })
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use(async (callback, options) => {
const root = await createTestProject() const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>() const sessions = new Map<string, string>()
const dirs = new Set<string>() const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra }) await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try { try {
await gotoSession() await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally { } finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
) )
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(root)
setHealthPhase(page, "test")
} }
}) })
}, },
@@ -95,6 +129,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
const win = window as E2EWindow const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: { terminal: {
enabled: true, enabled: true,
terminals: {}, terminals: {},
@@ -1,41 +1,19 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -76,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await workspaces(page, directory, true) await setWorkspacesEnabled(page, slug, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -100,11 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
// A new workspace can be discovered via a transient slug before the route and sidebar await waitSession(page, { directory: space })
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -118,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space) trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -125,14 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first() const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click({ force: true })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
@@ -1,109 +1,94 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
async function waitWorkspaceReady(page: Page, slug: string) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen]) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
const directory = base64Decode(slug) await waitDir(page, next.directory)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next return next
} }
async function createSessionFromWorkspace(page: Page, slug: string, text: string) { async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
const next = await openWorkspaceNewSession(page, slug) await waitWorkspaceReady(page, space)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text) await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await page.keyboard.press("Enter")
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) { await waitSessionSaved(space.directory, sessionID)
const info = await createSdk(directory) await createSdk(space.directory)
.session.get({ sessionID }) .session.abort({ sessionID })
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!info) return "" return sessionID
return info.directory
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, []) const first = await createWorkspace(page, root, [])
trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, root, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug) await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(firstSession.sessionID, first.directory) trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
}) })
}) })
+16 -14
View File
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
@@ -13,8 +13,10 @@ import {
confirmDialog, confirmDialog,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled, setWorkspacesEnabled,
slugFromUrl, slugFromUrl,
waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug]) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
) )
.toBe(true) .toBe(true)
return { rootSlug, slug, directory: dir } return { rootSlug, slug: next.slug, directory: next.directory }
} }
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug]) const next = await resolveSlug(await waitSlug(page, [slug]))
const workspaceDir = base64Decode(workspaceSlug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir) await cleanupTestProject(next.directory)
}) })
}) })
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url())) const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) { for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url()) const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug, prev]) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
workspaces.push({ slug, directory: dir }) workspaces.push(next)
await openSidebar(page) await openSidebar(page)
} }
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, draft) await wait(page, draft)
await edge(page, "start") // Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, second) await wait(page, second)
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second) await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, draft) await wait(page, "")
}) })
}) })
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await prompt.click() await prompt.focus()
await page.keyboard.type("line one") await expect(prompt).toBeFocused()
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two") await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one") await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
await expect(prompt).toContainText("line two")
}) })
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions" import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal") await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await expect(slash).toBeVisible() await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening, await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
}) })
+3
View File
@@ -13,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
export const promptModelSelector = '[data-component="prompt-model-control"]'
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]' export const settingsThemeSelector = '[data-action="settings-theme"]'
@@ -0,0 +1,359 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
model?: { providerID: string; modelID: string }
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const item = page
.locator('[data-slot="select-select-item"]')
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
.first()
await expect(item).toBeVisible()
await item.click()
}
async function variantCount(page: Page) {
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const count = await page.locator('[data-slot="select-select-item"]').count()
await page.keyboard.press("Escape")
return count
}
async function agents(page: Page) {
const select = page.locator(promptAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
await page.keyboard.press("Escape")
return labels.map((item) => item.trim()).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
if (count < 2) throw new Error("Current model has no alternate variant to select")
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
if (!next || next === current.variant) continue
await item.click()
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
throw new Error("Failed to choose a different variant")
}
async function chooseOtherModel(page: Page): Promise<Footer> {
const current = await read(page)
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
await expect(button).toBeVisible()
await button.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const items = dialog.locator('[data-slot="list-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const selected = (await item.getAttribute("data-selected")) === "true"
if (selected) continue
await item.click()
await expect(dialog).toHaveCount(0)
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
return read(page)
}
throw new Error("Failed to choose a different model")
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitSession(page, { directory: next.directory })
return next
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model and variant restore per session without leaking into new sessions", async ({
page,
withProject,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()
const fresh = await ensureVariant(page, directory)
expect(fresh.variant).not.toBe(firstState.variant)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
await ensureVariant(page, root)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
await ensureVariant(page, twoDir)
const thirdState = await chooseDifferentVariant(page)
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
@@ -123,6 +123,215 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
}, file) }, file)
} }
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions" import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible() await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent() const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P") expect(initialKeybind).toContain("K")
await keybindButton.click() await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i) await expect(keybindButton).toHaveText(/press/i)
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal }) await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
@@ -1,6 +1,16 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id }) await cleanupSession({ sdk, sessionID: two.id })
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions" import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
} }
await waitTerminalReady(page, { term: terminals.first() }) await waitTerminalFocusIdle(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back // Ghostty captures a lot of keybinds when focused; move focus back
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions" import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors" import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils" import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = { type State = {
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
.toEqual({ count: 1, first: true }) .toEqual({ count: 1, first: true })
}) })
}) })
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.25", "version": "1.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -51,12 +51,14 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "4.0.0-beta.31", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
+44 -31
View File
@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect" import { type Duration, Effect } from "effect"
import { import {
type Component, type Component,
@@ -46,21 +47,13 @@ import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
const Home = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => ( const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Suspense fallback={<Loading />}> <Session />
<Session />
</Suspense>
</SessionProviders> </SessionProviders>
) )
@@ -89,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
@@ -124,8 +122,10 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
{props.appChildren} <Suspense fallback={<Loading />}>
{props.children} {props.appChildren}
{props.children}
</Suspense>
</AppShellProviders> </AppShellProviders>
) )
} }
@@ -142,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider> <QueryProvider>
<MarkedProviderWithNativeParser> <DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <MarkedProviderWithNativeParser>
</MarkedProviderWithNativeParser> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider> </MarkedProviderWithNativeParser>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -265,6 +267,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
) )
} }
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
@@ -275,20 +286,22 @@ export function AppInterface(props: {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<GlobalSDKProvider> <ServerKey>
<GlobalSyncProvider> <GlobalSDKProvider>
<Dynamic <GlobalSyncProvider>
component={props.router ?? Router} <Dynamic
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} component={props.router ?? Router}
> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={HomeRoute} />
<Route path="/" component={SessionIndexRoute} /> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/" component={SessionIndexRoute} />
</Route> <Route path="/session/:id?" component={SessionRoute} />
</Dynamic> </Route>
</GlobalSyncProvider> </Dynamic>
</GlobalSDKProvider> </GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate> </ConnectionGate>
</ServerProvider> </ServerProvider>
) )
@@ -12,18 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog() const dialog = useDialog()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const alive = { value: true } const alive = { value: true }
@@ -49,13 +46,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action = type Action =
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -77,6 +75,11 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -120,7 +123,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback return fallback
} }
async function selectMethod(index: number) { async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = undefined timer.current = undefined
@@ -130,6 +133,10 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
@@ -137,6 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
inputs,
}, },
{ throwOnError: true }, { throwOnError: true },
) )
@@ -163,6 +171,122 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
} }
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<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 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -301,7 +425,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -314,12 +438,6 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -368,7 +486,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -386,10 +504,6 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
@@ -470,6 +584,9 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
@@ -34,7 +34,6 @@ export type FormState = {
apiKey: string apiKey: string
models: ModelRow[] models: ModelRow[]
headers: HeaderRow[] headers: HeaderRow[]
saving: boolean
err: { err: {
providerID?: string providerID?: string
name?: string name?: string
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} }, { row: "h1", key: "", value: "", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
apiKey: "", apiKey: "",
models: [modelRow()], models: [modelRow()],
headers: [headerRow()], headers: [headerRow()],
saving: false,
err: {}, err: {},
}) })
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
return output.result return output.result
} }
const save = async (e: SubmitEvent) => { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if (form.saving) return const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const result = validate() if (result.key) {
if (!result) return await globalSDK.client.auth.set({
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
key: result.key, key: result.key,
}, },
}) })
: Promise.resolve() }
auth await globalSync.updateConfig({
.then(() => provider: { [result.providerID]: result.config },
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), disabled_providers: nextDisabled,
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
}) })
.catch((err: unknown) => { return result
const message = err instanceof Error ? err.message : String(err) },
showToast({ title: language.t("common.requestFailed"), description: message }) onSuccess: (result) => {
}) dialog.close()
.finally(() => { showToast({
setForm("saving", false) variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
}) })
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
} }
return ( return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
</Button> </Button>
</div> </div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}> <Button
{form.saving ? language.t("common.saving") : language.t("common.submit")} class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false, dragOver: false,
iconHover: false, iconHover: false,
}) })
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "") setStore("iconUrl", "")
} }
async function handleSubmit(e: SubmitEvent) { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve() if (props.project.id && props.project.id !== "global") {
.then(async () => { await globalSDK.client.project.update({
setStore("saving", true) projectID: props.project.id,
const name = store.name.trim() === folderName() ? "" : store.name.trim() directory: props.project.worktree,
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name, name,
icon: { color: store.color, override: store.iconUrl || undefined }, icon: { color: store.color, override: store.iconUrl },
commands: { start: start || undefined }, commands: { start },
}) })
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close() dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
}) })
.finally(() => { dialog.close()
setStore("saving", false) },
}) }))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
} }
return ( return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}> <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{store.saving ? language.t("common.saving") : language.t("common.save")} {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
+3 -4
View File
@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
directory: sdk.directory, directory: sdk.directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk.directory)
sdk.client.session sdk.client.session
.fork({ sessionID, messageID: item.id }) .fork({ sessionID, messageID: item.id })
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
return return
} }
dialog.close() dialog.close()
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) prompt.set(restored, undefined, { dir, id: forked.data.id })
requestAnimationFrame(() => { navigate(`/${dir}/session/${forked.data.id}`)
prompt.set(restored)
})
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js" import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = async (name: string) => { const toggle = useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name)
try {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
} finally { },
setLoading(null) }))
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (x) toggle(x.name) if (!x || toggle.isPending) return
toggle.mutate(x.name)
}} }}
> >
{(i) => { {(i) => {
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show> </Show>
<Show when={loading() === i.name}> <Show when={toggle.isPending && toggle.variables === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show> </Show>
</div> </div>
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} /> <Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div> </div>
</div> </div>
) )
@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const DialogSelectModelUnpaid: Component = () => { type ModelState = ReturnType<typeof useLocal>["model"]
const local = useLocal()
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const language = useLanguage() const language = useLanguage()
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={local.model.list} items={model.list}
current={local.model.current()} current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => ( itemWrapper={(item, node) => (
<Tooltip <Tooltip
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
dialog.close() dialog.close()
@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
onSelect: () => void onSelect: () => void
action?: JSX.Element action?: JSX.Element
model?: ModelState
}> = (props) => { }> = (props) => {
const local = useLocal() const model = props.model ?? useLocal().model
const language = useLanguage() const language = useLanguage()
const models = createMemo(() => const models = createMemo(() =>
local.model model
.list() .list()
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)), .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
) )
@@ -41,7 +44,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
items={models} items={models}
current={local.model.current()} current={model.current()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name} groupBy={(x) => x.provider.name}
@@ -63,7 +66,7 @@ const ModelList: Component<{
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
props.onSelect() props.onSelect()
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title> <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
) )
} }
export const DialogSelectModel: Component<{ provider?: string }> = (props) => { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button> </Button>
} }
> >
<ModelList provider={props.provider} onSelect={() => dialog.close()} /> <ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button <Button
variant="ghost" variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start" class="ml-3 mt-5 mb-6 text-text-base self-start"
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
username: "", username: "",
password: "", password: "",
error: "", error: "",
busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false,
}) })
} }
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
@@ -291,12 +357,12 @@ export function DialogSelectServer() {
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
} }
const handleAddNameChange = (value: string) => { const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" }) setStore("addServer", { name: value, error: "" })
} }
const handleAddUsernameChange = (value: string) => { const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" }) setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) => void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
} }
const handleAddPasswordChange = (value: string) => { const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" }) setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) => void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
} }
const handleEditNameChange = (value: string) => { const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" }) setStore("editServer", { name: value, error: "" })
} }
const handleEditUsernameChange = (value: string) => { const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" }) setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) => void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
} }
const handleEditPasswordChange = (value: string) => { const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" }) setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) => void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
) )
} }
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => { const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit" if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add" if (store.addServer.showForm) return "add"
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
}) })
} }
const submitForm = () => { const submitForm = () => {
if (mode() === "add") { if (mode() === "add") {
void handleAdd(store.addServer.url) if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return return
} }
const original = editing() const original = editing()
if (!original) return if (!original) return
void handleEdit(original, store.editServer.value) if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
} }
const isFormMode = createMemo(() => mode() !== "list") const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add") const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => { const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title") if (!isFormMode()) return language.t("dialog.server.title")
+183 -151
View File
@@ -1,8 +1,7 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { import {
@@ -37,6 +36,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments" import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
@@ -121,7 +121,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 52 const inset = 56
const space = `${inset}px` const space = `${inset}px`
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
@@ -244,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -411,7 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click() const pick = () => fileInputRef?.click()
@@ -606,6 +622,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
promptProbe.select(cmd.id)
closePopover() closePopover()
if (cmd.type === "custom") { if (cmd.type === "custom") {
@@ -694,6 +711,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
}) })
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -1012,9 +1043,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => { focusEditor: () => {
@@ -1031,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
@@ -1200,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Note: Shift+Enter is handled earlier, before IME check // Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
return
}
handleSubmit(event) handleSubmit(event)
} }
} }
@@ -1328,18 +1383,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] const list = e.currentTarget.files
if (file) void addAttachment(file) if (list) void addAttachments(Array.from(list))
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div <div
aria-hidden={store.mode !== "normal"} aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1" class="pointer-events-auto"
style={{ style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}} }}
@@ -1363,81 +1437,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="plus" class="size-4.5" /> <Icon name="plus" class="size-4.5" />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t(
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": accepting() }}
/>
</Button>
</TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@@ -1457,39 +1456,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind <div data-component="prompt-agent-control">
placement="top" <TooltipKeybind
gutter={4} placement="top"
title={language.t("command.agent.cycle")} gutter={4}
keybind={command.keybind("agent.cycle")} title={language.t("command.agent.cycle")}
> keybind={command.keybind("agent.cycle")}
<Select >
size="normal" <Select
options={agentNames()} size="normal"
current={local.agent.current()?.name ?? ""} options={agentNames()}
onSelect={local.agent.set} current={local.agent.current()?.name ?? ""}
class="capitalize max-w-[160px]" onSelect={local.agent.set}
valueClass="truncate text-13-regular" class="capitalize max-w-[160px] text-text-base"
triggerStyle={control()} valueClass="truncate text-13-regular text-text-base"
variant="ghost" triggerStyle={control()}
/> triggerProps={{ "data-action": "prompt-agent" }}
</TooltipKeybind> variant="ghost"
<Show />
when={providers.paid().length > 0} </TooltipKeybind>
fallback={ </div>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button <ModelSelectorPopover
as="div" model={local.model}
variant="ghost" triggerAs={Button}
size="normal" triggerProps={{
class="min-w-0 max-w-[320px] text-13-regular group" variant: "ghost",
style={control()} size: "normal",
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1502,56 +1538,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</Button> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
} </Show>
> </div>
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.variant.cycle")}
> >
<ModelSelectorPopover <Select
triggerAs={Button} size="normal"
triggerProps={{ options={variants()}
variant: "ghost", current={local.model.variant.current() ?? "default"}
size: "normal", label={(x) => (x === "default" ? language.t("common.default") : x)}
style: control(), onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class: "min-w-0 max-w-[320px] text-13-regular group", class="capitalize max-w-[160px] text-text-base"
}} valueClass="truncate text-13-regular text-text-base"
> triggerStyle={control()}
<Show when={local.model.current()?.provider?.id}> triggerProps={{ "data-action": "prompt-model-variant" }}
<ProviderIcon variant="ghost"
id={local.model.current()!.provider.id} />
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </div>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
title={language.t("command.model.variant.cycle")} title={acceptLabel()}
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("permissions.autoaccept")}
> >
<Select <Button
size="normal" data-action="prompt-permissions"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
variant="ghost" variant="ghost"
/> onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => { describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => { test("keeps PDFs when the browser reports the mime", async () => {
@@ -22,3 +23,22 @@ describe("attachmentMime", () => {
expect(await attachmentMime(file)).toBeUndefined() expect(await attachmentMime(file)).toBeUndefined()
}) })
}) })
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
@@ -5,8 +5,7 @@ import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
const LARGE_PASTE_CHARS = 8000 import { normalizePaste, pasteMode } from "./paste"
const LARGE_PASTE_BREAKS = 120
function dataUrl(file: File, mime: string) { function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => { return new Promise<string>((resolve) => {
@@ -25,20 +24,8 @@ function dataUrl(file: File, mime: string) {
}) })
} }
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
@@ -84,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file) const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
const current = prompt.current() const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -91,25 +90,20 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const items = Array.from(clipboardData.items) const files = Array.from(clipboardData.items).flatMap((item) => {
const fileItems = items.filter((item) => item.kind === "file") if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (fileItems.length > 0) { if (files.length > 0) {
let found = false await addAttachments(files)
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return return
} }
@@ -126,16 +120,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (!plainText) return if (!plainText) return
if (largePaste(plainText)) { const text = normalizePaste(plainText)
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor() input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return return input.addPart({ type: "text", content: text, start: 0, end: 0 })
} }
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) if (pasteMode(text) === "manual") {
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return if (inserted) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) put()
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
@@ -176,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
let found = false await addAttachments(Array.from(dropped))
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
} }
onMount(() => { onMount(() => {
@@ -198,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return { return {
addAttachment, addAttachment,
addAttachments,
removeAttachment, removeAttachment,
handlePaste, handlePaste,
} }
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
}) })
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -1,4 +1,6 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
"application/yaml", "application/yaml",
]) ])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096 const SAMPLE = 4096
function kind(type: string) { function kind(type: string) {
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc" const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0 const atStart = position === 0
const atEnd = position === text.length const atEnd = position === text.length
if (inHistory) return atStart || atEnd if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 if (direction === "up") return position === 0 && text.length === 0
return position === text.length return position === text.length
} }
@@ -0,0 +1,24 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}
@@ -17,6 +17,7 @@ const optimistic: Array<{
}> = [] }> = []
const optimisticSeeded: boolean[] = [] const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -86,6 +87,11 @@ beforeAll(async () => {
agent: { agent: {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}), }),
})) }))
@@ -201,6 +207,7 @@ beforeEach(() => {
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0 optimisticSeeded.length = 0
promoted.length = 0
params = {} params = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
@@ -240,6 +247,11 @@ describe("prompt submit worktree selection", () => {
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => { test("applies auto-accept to newly created sessions", async () => {
@@ -296,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current() const currentModel = local.model.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) { if (!currentModel || !currentAgent) {
showToast({ showToast({
title: language.t("prompt.toast.modelAgentRequired.title"), title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -370,6 +371,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
seed(sessionDirectory, created) seed(sessionDirectory, created)
session = created session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -387,7 +389,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice() const context = prompt.context.items().slice()
const draft: FollowupDraft = { const draft: FollowupDraft = {
sessionID: session.id, sessionID: session.id,
@@ -267,14 +267,14 @@ export function SessionContextTab() {
return ( return (
<ScrollView <ScrollView
class="@container h-full pb-10" class="@container h-full"
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div class="px-6 pt-4 flex flex-col gap-10"> <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}> <For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />} {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
@@ -16,9 +16,11 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers" import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -132,6 +134,7 @@ export function SessionHeader() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
@@ -218,6 +221,9 @@ export function SessionHeader() {
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
) )
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const selectApp = (app: OpenApp) => { const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return if (!options().some((item) => item.id === app)) return
@@ -268,12 +274,11 @@ export function SessionHeader() {
type="button" type="button"
variant="ghost" variant="ghost"
size="small" size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default" class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")} aria-label={language.t("session.header.searchFiles")}
> >
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible"> <div class="flex min-w-0 flex-1 items-center overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left"> <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", { {language.t("session.header.search.placeholder", {
project: name(), project: name(),
@@ -320,7 +325,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default" class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{ classList={{
"bg-surface-raised-base-active": opening(), "bg-surface-raised-base-active": opening(),
}} }}
@@ -330,10 +335,9 @@ export function SessionHeader() {
> >
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5"> <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}> <Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5 text-icon-base" /> <Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show> </Show>
</div> </div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button> </Button>
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
}) })
let input: HTMLInputElement | undefined let input: HTMLInputElement | undefined
let blurFrame: number | undefined let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
left: `${store.menuPosition.x}px`, left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`, top: `${store.menuPosition.y}px`,
}} }}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
> >
<DropdownMenu.Item onSelect={edit}> <DropdownMenu.Item onSelect={() => (editRequested = true)}>
<Icon name="edit" class="w-4 h-4 mr-2" /> <Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")} {language.t("common.rename")}
</DropdownMenu.Item> </DropdownMenu.Item>
+28 -32
View File
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -130,41 +131,30 @@ const useDefaultServerKey = (
} }
} }
const useMcpToggle = (input: { const useMcpToggleMutation = () => {
sync: ReturnType<typeof useSync> const sync = useSync()
sdk: ReturnType<typeof useSDK> const sdk = useSDK()
language: ReturnType<typeof useLanguage> const language = useLanguage()
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => { return useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name) const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
try { const result = await sdk.client.mcp.status()
const status = input.sync.data.mcp[name] if (result.data) sync.set("mcp", result.data)
await (status?.status === "connected" },
? input.sdk.client.mcp.disconnect({ name }) onError: (err) => {
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({ showToast({
variant: "error", variant: "error",
title: input.language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
}) })
} finally { },
setLoading(null) }))
}
}
return { loading, toggle }
} }
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
@@ -181,7 +171,7 @@ export function StatusPopover() {
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -277,8 +267,8 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(key)
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={health[key]} /> <ServerHealthIndicator health={health[key]} />
@@ -337,8 +327,11 @@ export function StatusPopover() {
<button <button
type="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" 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={() => mcp.toggle(name)} onClick={() => {
disabled={mcp.loading() === name} if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
> >
<div <div
classList={{ classList={{
@@ -354,8 +347,11 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={mcp.loading() === name} disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => mcp.toggle(name)} onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/> />
</div> </div>
</button> </button>
+20 -17
View File
@@ -65,14 +65,11 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values) console.debug("[terminal]", ...values)
} }
const errorStatus = (err: unknown) => { const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return if (!err || typeof err !== "object") return
if (!("data" in err)) return if (!("name" in err)) return
const data = err.data const errorName = err.name
if (!data || typeof data !== "object") return return typeof errorName === "string" ? errorName : undefined
if (!("statusCode" in data)) return
const status = data.statusCode
return typeof status === "number" ? status : undefined
} }
const useTerminalUiBindings = (input: { const useTerminalUiBindings = (input: {
@@ -168,6 +165,12 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -218,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
return sdk.client.pty return client.pty
.update({ .update({
ptyID: id, ptyID: id,
size: { cols, rows }, size: { cols, rows },
@@ -477,11 +480,11 @@ export const Terminal = (props: TerminalProps) => {
} }
const gone = () => const gone = () =>
sdk.client.pty client.pty
.get({ ptyID: id }) .get({ ptyID: id })
.then(() => false) .then(() => false)
.catch((err) => { .catch((err) => {
if (errorStatus(err) === 404) return true if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
@@ -509,14 +512,14 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return if (disposed) return
drop?.() drop?.()
const url = new URL(sdk.url + `/pty/${id}/connect`) const next = new URL(url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory) next.searchParams.set("directory", directory)
url.searchParams.set("cursor", String(seek)) next.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode" next.username = username
url.password = server.current?.http.password ?? "" next.password = password
const socket = new WebSocket(url) const socket = new WebSocket(next)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
+64 -38
View File
@@ -77,6 +77,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0) const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1) const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const back = () => { const back = () => {
const next = backPath(history) const next = backPath(history)
@@ -217,47 +218,72 @@ export function Titlebar() {
</TooltipKeybind> </TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0"> <div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}> <Show when={params.dir}>
<TooltipKeybind <div
placement="bottom" class="flex items-center shrink-0 w-8 mr-1"
title={language.t("command.session.new")} aria-hidden={layout.sidebar.opened() ? "true" : undefined}
keybind={command.keybind("session.new")}
openDelay={2000}
> >
<Button <div
variant="ghost" class="transition-opacity"
icon={creating() ? "new-session-active" : "new-session"} classList={{
class="titlebar-icon w-8 h-6 p-0 box-border" "opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
onClick={() => { "opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
if (!params.dir) return
navigate(`/${params.dir}/session`)
}} }}
aria-label={language.t("command.session.new")} >
aria-current={creating() ? "page" : undefined} <TooltipKeybind
/> placement="bottom"
</TooltipKeybind> title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<Show when={hasProjects()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show> </Show>
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
+89
View File
@@ -0,0 +1,89 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true) expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("") expect(formatKeybind("none")).toBe("")
}) })
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
}) })
+19 -15
View File
@@ -1,10 +1,10 @@
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { dict as en } from "@/i18n/en" import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -238,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}) })
const warnedDuplicates = new Set<string>() const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted( const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"), Persist.global("command.catalog.v1"),
createStore<Record<string, CommandCatalogItem>>({}), createStore<CommandCatalog>({}),
) )
const bind = (id: string, def: KeybindConfig | undefined) => { const bind = (id: string, def: KeybindConfig | undefined) => {
@@ -259,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (seen.has(opt.id)) { if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
warnedDuplicates.add(opt.id) warnedDuplicates.add(opt.id)
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
} }
continue continue
} }
@@ -274,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => { createEffect(() => {
if (!catalogReady()) return if (!catalogReady()) return
for (const opt of registered()) { setCatalog(
const id = actionId(opt.id) registered().reduce((acc, opt) => {
setCatalog(id, { const id = actionId(opt.id)
title: opt.title, acc[id] = {
description: opt.description, title: opt.title,
category: opt.category, description: opt.description,
keybind: opt.keybind, category: opt.category,
slash: opt.slash, keybind: opt.keybind,
}) slash: opt.slash,
} }
return acc
}, {} as CommandCatalog),
)
}) })
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta }))) const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
+1
View File
@@ -378,6 +378,7 @@ function createGlobalSync() {
return globalStore.error return globalStore.error
}, },
child: children.child, child: children.child,
peek: children.peek,
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
return childStore return childStore
} }
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory) const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
children, children,
ensureChild, ensureChild,
child, child,
peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
mark, mark,
+373 -203
View File
@@ -1,252 +1,422 @@
import { createStore } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers"
import { modelEnabled, modelProbe } from "@/testing/model-selection"
import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string } export type ModelKey = { providerID: string; modelID: string }
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return undefined
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies State
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const providers = useProviders() const providers = useProviders()
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id))) const models = useModels()
function isModelValid(model: ModelKey) { const id = createMemo(() => params.id || undefined)
const provider = providers.all().find((x) => x.id === model.providerID) const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const [store, setStore] = createStore<{
current?: string
draft?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
const provider = providers.all().find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID) return !!provider?.models[model.modelID] && connected().has(model.providerID)
} }
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { const firstModel = (...items: Array<() => ModelKey | undefined>) => {
for (const modelFn of modelFns) { for (const item of items) {
const model = modelFn() const model = item()
if (!model) continue if (!model) continue
if (isModelValid(model)) return model if (validModel(model)) return model
} }
} }
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return undefined
return items.find((item) => item.name === name) ?? items[0]
}
const agent = (() => { createEffect(() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const items = list()
const models = useModels() if (items.length === 0) {
if (store.current !== undefined) setStore("current", undefined)
return
}
if (items.some((item) => item.name === store.current)) return
setStore("current", items[0]?.name)
})
const [store, setStore] = createStore<{ const scope = createMemo<State | undefined>(() => {
current?: string const session = id()
}>({ if (!session) return store.draft
current: list()[0]?.name, return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(sdk.directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
handoff.delete(key)
return
}
setSaved("session", session, clone(next))
handoff.delete(key)
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
const recentModel = () => {
for (const item of models.recent.list()) {
if (validModel(item)) return item
}
}
const defaultModel = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const model = { providerID: provider.id, modelID: configured }
if (validModel(model)) return model
}
const first = Object.values(provider.models)[0]
if (!first) continue
const model = { providerID: provider.id, modelID: first.id }
if (validModel(model)) return model
}
}
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
const agent = {
list,
current() {
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
const item = pickAgent(name)
if (!item) {
setStore("current", undefined)
return
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
})
},
move(direction: 1 | -1) {
const items = list()
if (items.length === 0) {
setStore("current", undefined)
return
}
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
const item = items[next]
if (!item) return
agent.set(item.name)
},
}
const current = () => {
const item = firstModel(
() => scope()?.model,
() => agent.current()?.model,
fallback,
)
if (!item) return undefined
return models.find(item)
}
const configured = () => {
const item = agent.current()
const model = current()
if (!item || !model) return undefined
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) })
}
const selected = () => scope()?.variant
const snapshot = () => {
const model = current()
return { return {
list, agent: agent.current()?.name,
current() { model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
const available = list() variant: selected(),
if (available.length === 0) return undefined } satisfies State
return available.find((x) => x.name === store.current) ?? available[0] }
},
set(name: string | undefined) { const write = (next: Partial<State>) => {
const available = list() const state = {
if (available.length === 0) { ...(scope() ?? { agent: agent.current()?.name }),
setStore("current", undefined) ...next,
return } satisfies State
}
const match = name ? available.find((x) => x.name === name) : undefined const session = id()
const value = match ?? available[0] if (session) {
if (!value) return setSaved("session", session, state)
setStore("current", value.name) return
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
move(direction: 1 | -1) {
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
let next = available.findIndex((x) => x.name === store.current) + direction
if (next < 0) next = available.length - 1
if (next >= available.length) next = 0
const value = available[next]
if (!value) return
setStore("current", value.name)
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
} }
})() setStore("draft", state)
}
const model = (() => { const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const models = useModels()
const [ephemeral, setEphemeral] = createStore<{ const model = {
model: Record<string, ModelKey | undefined> ready: models.ready,
}>({ current,
model: {}, recent,
}) list: models.list,
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const resolveConfigured = () => { const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const key = { providerID, modelID }
if (isModelValid(key)) return key
}
const resolveRecent = () => {
for (const item of models.recent.list()) {
if (isModelValid(item)) return item
}
}
const resolveDefault = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(provider.models)[0]
if (!first) continue
const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
}
const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel(
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)
if (!key) return undefined
return models.find(key)
})
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const currentModel = current()
if (!currentModel) return
const index = recentList.findIndex(
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
)
if (index === -1) return if (index === -1) return
let next = index + direction let next = index + direction
if (next < 0) next = recentList.length - 1 if (next < 0) next = items.length - 1
if (next >= recentList.length) next = 0 if (next >= items.length) next = 0
const val = recentList[next] const entry = items[next]
if (!val) return if (!entry) return
model.set({ providerID: entry.provider.id, modelID: entry.id })
model.set({ },
providerID: val.provider.id, set(item: ModelKey | undefined, options?: { recent?: boolean }) {
modelID: val.id,
})
}
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
batch(() => { batch(() => {
const currentAgent = agent.current() setStore("last", {
const next = model ?? fallbackModel() type: "model",
if (currentAgent) setEphemeral("model", currentAgent.name, next) agent: agent.current()?.name,
if (model) models.setVisibility(model, true) model: item ?? null,
if (options?.recent && model) models.recent.push(model) variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}) })
} },
visible(item: ModelKey) {
setModel = set return models.visible(item)
},
return { setVisibility(item: ModelKey, visible: boolean) {
ready: models.ready, models.setVisibility(item, visible)
current, },
recent, variant: {
list: models.list, configured,
cycle, selected,
set, current() {
visible(model: ModelKey) { return resolveModelVariant({
return models.visible(model) variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
}, },
setVisibility(model: ModelKey, visible: boolean) { list() {
models.setVisibility(model, visible) const item = current()
if (!item?.variants) return []
return Object.keys(item.variants)
}, },
variant: { set(value: string | undefined) {
configured() { batch(() => {
const a = agent.current() const model = current()
const m = current() setStore("last", {
if (!a || !m) return undefined type: "variant",
return getConfiguredAgentVariant({ agent: agent.current()?.name,
agent: { model: a.model, variant: a.variant }, model: model ? { providerID: model.provider.id, modelID: model.id } : null,
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants }, variant: value ?? null,
}) })
}, write({ variant: value ?? null })
selected() { })
const m = current() },
if (!m) return undefined cycle() {
return models.variant.get({ providerID: m.provider.id, modelID: m.id }) const items = this.list()
}, if (items.length === 0) return
current() { this.set(
return resolveModelVariant({ cycleModelVariant({
variants: this.list(), variants: items,
selected: this.selected(), selected: this.selected(),
configured: this.configured(), configured: this.configured(),
}) }),
}, )
list() {
const m = current()
if (!m) return []
if (!m.variants) return []
return Object.keys(m.variants)
},
set(value: string | undefined) {
const m = current()
if (!m) return
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
}, },
} },
})() }
const result = { const result = {
slug: createMemo(() => base64Encode(sdk.directory)), slug: createMemo(() => base64Encode(sdk.directory)),
model, model,
agent, agent,
session: {
reset() {
setStore("draft", undefined)
},
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.variant ?? null,
})
},
},
} }
if (modelEnabled()) {
createEffect(() => {
const agent = result.agent.current()
const model = result.model.current()
modelProbe.set({
dir: sdk.directory,
sessionID: id(),
last: store.last,
agent: agent?.name,
model: model
? {
providerID: model.provider.id,
modelID: model.id,
name: model.name,
}
: undefined,
variant: result.model.variant.current() ?? null,
selected: result.model.variant.selected(),
configured: result.model.variant.configured(),
pick: scope(),
base: undefined,
current: store.current,
})
})
onCleanup(() => modelProbe.clear())
}
return result return result
}, },
}) })
@@ -44,6 +44,16 @@ describe("model variant", () => {
expect(value).toBe("high") expect(value).toBe("high")
}) })
test("lets an explicit default override the configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBeUndefined()
})
test("cycles from configured variant to next", () => { test("cycles from configured variant to next", () => {
const value = cycleModelVariant({ const value = cycleModelVariant({
variants: ["low", "high", "xhigh"], variants: ["low", "high", "xhigh"],
@@ -63,4 +73,14 @@ describe("model variant", () => {
expect(value).toBe("low") expect(value).toBe("low")
}) })
test("cycles from an explicit default to the first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBe("low")
})
}) })
+3 -1
View File
@@ -14,7 +14,7 @@ type Model = AgentModel & {
type VariantInput = { type VariantInput = {
variants: string[] variants: string[]
selected: string | undefined selected: string | null | undefined
configured: string | undefined configured: string | undefined
} }
@@ -29,6 +29,7 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
} }
export function resolveModelVariant(input: VariantInput) { export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined return undefined
@@ -36,6 +37,7 @@ export function resolveModelVariant(input: VariantInput) {
export function cycleModelVariant(input: VariantInput) { export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) { if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected) const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined if (index === input.variants.length - 1) return undefined
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }
+19 -9
View File
@@ -1,10 +1,10 @@
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { checksum } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase { interface PartBase {
content: string content: string
@@ -151,6 +151,11 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession> type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
}
type PromptCacheEntry = { type PromptCacheEntry = {
value: PromptSession value: PromptSession
dispose: VoidFunction dispose: VoidFunction
@@ -245,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
} }
const owner = getOwner()
const load = (dir: string, id: string | undefined) => { const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}` const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key) const existing = cache.get(key)
@@ -254,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return existing.value return existing.value
} }
const entry = createRoot((dispose) => ({ const entry = createRoot(
value: createPromptSession(dir, id), (dispose) => ({
dispose, value: createPromptSession(dir, id),
})) dispose,
}),
owner,
)
cache.set(key, entry) cache.set(key, entry)
prune() prune()
@@ -265,6 +274,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return { return {
ready: () => session().ready(), ready: () => session().ready(),
@@ -280,8 +290,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next), session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: () => session().reset(), reset: (scope?: Scope) => pick(scope).reset(),
} }
}, },
}) })
+73 -49
View File
@@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
onCleanup(unsub) onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
}
})
}
return { return {
ready, ready,
all: createMemo(() => store.all), all: createMemo(() => store.all),
@@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
const index = store.all.findIndex((x) => x.id === pty.id) update(sdk.client, pty)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}, },
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
async clone(id: string) { async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id) await clone(sdk.client, id)
const pty = store.all[index] },
if (!pty) return bind() {
const clone = await sdk.client.pty const client = sdk.client
.create({ return {
title: pty.title, trim(id: string) {
}) const index = store.all.findIndex((x) => x.id === id)
.catch((error: unknown) => { if (index === -1) return
console.error("Failed to clone terminal", error) setStore("all", index, (pty) => trimTerminal(pty))
return undefined },
}) update(pty: Partial<LocalPTY> & { id: string }) {
if (!clone?.data) return update(client, pty)
},
const active = store.active === pty.id async clone(id: string) {
await clone(client, id)
batch(() => { },
setStore("all", index, { }
id: clone.data.id,
title: clone.data.title ?? pty.title,
titleNumber: pty.titleNumber,
// New PTY process, so start clean.
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", clone.data.id)
}
})
}, },
open(id: string) { open(id: string) {
setStore("active", id) setStore("active", id)
@@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id), trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(), trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id), clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id), open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id), close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to), move: (id: string, to: number) => workspace().move(id, to),
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "إلغاء", "common.cancel": "إلغاء",
"common.connect": "اتصال", "common.connect": "اتصال",
"common.disconnect": "قطع الاتصال", "common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
"common.submit": "إرسال", "common.submit": "إرسال",
"common.save": "حفظ", "common.save": "حفظ",
"common.saving": "جارٍ الحفظ...", "common.saving": "جارٍ الحفظ...",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Salvar", "common.save": "Salvar",
"common.saving": "Salvando...", "common.saving": "Salvando...",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.cancel": "Otkaži", "common.cancel": "Otkaži",
"common.connect": "Poveži", "common.connect": "Poveži",
"common.disconnect": "Prekini vezu", "common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
"common.submit": "Pošalji", "common.submit": "Pošalji",
"common.save": "Sačuvaj", "common.save": "Sačuvaj",
"common.saving": "Čuvanje...", "common.saving": "Čuvanje...",
+1
View File
@@ -219,6 +219,7 @@ export const dict = {
"common.cancel": "Annuller", "common.cancel": "Annuller",
"common.connect": "Forbind", "common.connect": "Forbind",
"common.disconnect": "Frakobl", "common.disconnect": "Frakobl",
"common.continue": "Indsend",
"common.submit": "Indsend", "common.submit": "Indsend",
"common.save": "Gem", "common.save": "Gem",
"common.saving": "Gemmer...", "common.saving": "Gemmer...",
+1
View File
@@ -209,6 +209,7 @@ export const dict = {
"common.cancel": "Abbrechen", "common.cancel": "Abbrechen",
"common.connect": "Verbinden", "common.connect": "Verbinden",
"common.disconnect": "Trennen", "common.disconnect": "Trennen",
"common.continue": "Absenden",
"common.submit": "Absenden", "common.submit": "Absenden",
"common.save": "Speichern", "common.save": "Speichern",
"common.saving": "Speichert...", "common.saving": "Speichert...",
+6 -1
View File
@@ -23,6 +23,8 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar", "command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project", "command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider", "command.provider.connect": "Connect provider",
"command.server.switch": "Switch server", "command.server.switch": "Switch server",
"command.settings.open": "Open settings", "command.settings.open": "Open settings",
@@ -221,6 +223,7 @@ export const dict = {
"common.open": "Open", "common.open": "Open",
"common.connect": "Connect", "common.connect": "Connect",
"common.disconnect": "Disconnect", "common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.submit": "Submit", "common.submit": "Submit",
"common.save": "Save", "common.save": "Save",
"common.saving": "Saving...", "common.saving": "Saving...",
@@ -273,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file", "prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context", "prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add file", "prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment", "prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
@@ -674,6 +677,8 @@ export const dict = {
"sidebar.project.recentSessions": "Recent sessions", "sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications", "sidebar.project.clearNotifications": "Clear notifications",
"sidebar.empty.title": "No projects open",
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics", "debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.na": "n/a", "debugBar.na": "n/a",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Guardar", "common.save": "Guardar",
"common.saving": "Guardando...", "common.saving": "Guardando...",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Annuler", "common.cancel": "Annuler",
"common.connect": "Connecter", "common.connect": "Connecter",
"common.disconnect": "Déconnecter", "common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
"common.submit": "Soumettre", "common.submit": "Soumettre",
"common.save": "Enregistrer", "common.save": "Enregistrer",
"common.saving": "Enregistrement...", "common.saving": "Enregistrement...",
+1
View File
@@ -203,6 +203,7 @@ export const dict = {
"common.cancel": "キャンセル", "common.cancel": "キャンセル",
"common.connect": "接続", "common.connect": "接続",
"common.disconnect": "切断", "common.disconnect": "切断",
"common.continue": "送信",
"common.submit": "送信", "common.submit": "送信",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
+1
View File
@@ -207,6 +207,7 @@ export const dict = {
"common.cancel": "취소", "common.cancel": "취소",
"common.connect": "연결", "common.connect": "연결",
"common.disconnect": "연결 해제", "common.disconnect": "연결 해제",
"common.continue": "제출",
"common.submit": "제출", "common.submit": "제출",
"common.save": "저장", "common.save": "저장",
"common.saving": "저장 중...", "common.saving": "저장 중...",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"common.cancel": "Avbryt", "common.cancel": "Avbryt",
"common.connect": "Koble til", "common.connect": "Koble til",
"common.disconnect": "Koble fra", "common.disconnect": "Koble fra",
"common.continue": "Send inn",
"common.submit": "Send inn", "common.submit": "Send inn",
"common.save": "Lagre", "common.save": "Lagre",
"common.saving": "Lagrer...", "common.saving": "Lagrer...",
+1
View File
@@ -205,6 +205,7 @@ export const dict = {
"common.cancel": "Anuluj", "common.cancel": "Anuluj",
"common.connect": "Połącz", "common.connect": "Połącz",
"common.disconnect": "Rozłącz", "common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
"common.submit": "Prześlij", "common.submit": "Prześlij",
"common.save": "Zapisz", "common.save": "Zapisz",
"common.saving": "Zapisywanie...", "common.saving": "Zapisywanie...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Отмена", "common.cancel": "Отмена",
"common.connect": "Подключить", "common.connect": "Подключить",
"common.disconnect": "Отключить", "common.disconnect": "Отключить",
"common.continue": "Отправить",
"common.submit": "Отправить", "common.submit": "Отправить",
"common.save": "Сохранить", "common.save": "Сохранить",
"common.saving": "Сохранение...", "common.saving": "Сохранение...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "ยกเลิก", "common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ", "common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ", "common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
"common.submit": "ส่ง", "common.submit": "ส่ง",
"common.save": "บันทึก", "common.save": "บันทึก",
"common.saving": "กำลังบันทึก...", "common.saving": "กำลังบันทึก...",
+1
View File
@@ -225,6 +225,7 @@ export const dict = {
"common.cancel": "İptal", "common.cancel": "İptal",
"common.connect": "Bağlan", "common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes", "common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
"common.submit": "Gönder", "common.submit": "Gönder",
"common.save": "Kaydet", "common.save": "Kaydet",
"common.saving": "Kaydediliyor...", "common.saving": "Kaydediliyor...",
+1
View File
@@ -242,6 +242,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "连接", "common.connect": "连接",
"common.disconnect": "断开连接", "common.disconnect": "断开连接",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "連線", "common.connect": "連線",
"common.disconnect": "中斷連線", "common.disconnect": "中斷連線",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "儲存", "common.save": "儲存",
"common.saving": "儲存中...", "common.saving": "儲存中...",
+1
View File
@@ -1,4 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
+48 -53
View File
@@ -1,16 +1,15 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { DataProvider } from "@opencode-ai/ui/context"
import { createStore } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
@@ -30,61 +29,57 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "") const navigate = useNavigate()
const [state, setState] = createStore({ invalid: "", resolved: "" }) let invalid = ""
createEffect(() => { const [resolved] = createResource(
if (!params.dir) return () => {
const raw = directory() if (params.dir) return [location.pathname, params.dir] as const
if (!raw) { },
if (state.invalid === params.dir) return async ([pathname, b64Dir]) => {
setState("invalid", params.dir) const directory = decode64(b64Dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
const current = params.dir if (!directory) {
globalSDK if (invalid === params.dir) return
.createClient({ invalid = b64Dir
directory: raw, showToast({
throwOnError: true, variant: "error",
}) title: language.t("common.requestFailed"),
.path.get() description: language.t("directory.error.invalidUrl"),
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
}) })
if (next === raw) return navigate("/", { replace: true })
const path = location.pathname.slice(current.length + 1) return
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }
})
.catch(() => { return await globalSDK
if (params.dir !== current) return .createClient({
batch(() => { directory,
setState("invalid", "") throwOnError: true,
setState("resolved", raw)
}) })
}) .path.get()
}) .then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
return ( return (
<Show when={state.resolved}> <Show when={resolved()} keyed>
{(resolved) => ( {(resolved) => (
<SDKProvider directory={resolved}> <SDKProvider directory={() => resolved}>
<SyncProvider> <SyncProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider> <DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
</SyncProvider> </SyncProvider>
</SDKProvider> </SDKProvider>
)} )}
+9 -1
View File
@@ -1,11 +1,12 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js" import { Component, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = { export type InitError = {
name: string name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined, actionError: undefined as string | undefined,
}) })
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
+393 -249
View File
@@ -1,4 +1,17 @@
import { batch, createEffect, createMemo, For, on, onCleanup, onMount, ParentProps, Show, untrack } from "solid-js" import {
batch,
createEffect,
createMemo,
createResource,
For,
on,
onCleanup,
onMount,
ParentProps,
Show,
untrack,
type Accessor,
} from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -116,6 +129,16 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -124,7 +147,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark", dark: "theme.scheme.dark",
} }
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => decode64(params.dir) ?? "") const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
@@ -135,7 +158,7 @@ export default function Layout(props: ParentProps) {
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
sortNow: Date.now(), sortNow: Date.now(),
sizing: false, sizing: false,
peek: undefined as LocalProject | undefined, peek: undefined as string | undefined,
peeked: false, peeked: false,
}) })
@@ -188,13 +211,22 @@ export default function Layout(props: ParentProps) {
onMount(() => { onMount(() => {
const stop = () => setState("sizing", false) const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop) window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop) window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop) window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pointerup", stop) window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop) window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop) window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
}) })
}) })
@@ -214,6 +246,12 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined navLeave.current = undefined
} }
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => { const arm = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return if (state.hoverProject === undefined) return
@@ -233,6 +271,12 @@ export default function Layout(props: ParentProps) {
return layout.projects.list().find((project) => project.worktree === id) return layout.projects.list().find((project) => project.worktree === id)
}) })
const peekProject = createMemo(() => {
const id = state.peek
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => { createEffect(() => {
const p = hoverProjectData() const p = hoverProjectData()
if (p) { if (p) {
@@ -240,7 +284,7 @@ export default function Layout(props: ParentProps) {
clearTimeout(peekt) clearTimeout(peekt)
peekt = undefined peekt = undefined
} }
setState("peek", p) setState("peek", p.worktree)
setState("peeked", true) setState("peeked", true)
return return
} }
@@ -259,16 +303,6 @@ export default function Layout(props: ParentProps) {
setHoverProject(undefined) setHoverProject(undefined)
}) })
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length > 0) return true
return !!server.projects.last()
})
createEffect(() => { createEffect(() => {
if (!state.autoselect) return if (!state.autoselect) return
const dir = params.dir const dir = params.dir
@@ -286,8 +320,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => { const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
setState("hoverSession", undefined) reset()
setHoverProject(undefined)
} }
const navigateWithSidebarReset = (href: string) => { const navigateWithSidebarReset = (href: string) => {
@@ -475,8 +508,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (directory === currentDir() && props.sessionID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -534,13 +567,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => { const currentProject = createMemo(() => {
const directory = currentDir() const directory = currentDir()
if (!directory) return if (!directory) return
const key = workspaceKey(directory)
const projects = layout.projects.list() const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
if (sandbox) return sandbox if (sandbox) return sandbox
const direct = projects.find((p) => p.worktree === directory) const direct = projects.find((p) => workspaceKey(p.worktree) === key)
if (direct) return direct if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false }) const [child] = globalSync.child(directory, { bootstrap: false })
@@ -554,33 +588,23 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root) return projects.find((p) => p.worktree === root)
}) })
createEffect( const [autoselecting] = createResource(async () => {
on( await ready.promise
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), await layout.ready.promise
(value) => { if (!untrack(() => state.autoselect)) return
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (value.dir) return
const last = server.projects.last() const list = layout.projects.list()
const last = server.projects.last()
if (value.list.length === 0) { if (list.length === 0) {
if (!last) return if (!last) return
setState("autoselect", false) await openProject(last, true)
openProject(last, false) } else {
navigateToProject(last) const next = list.find((project) => project.worktree === last) ?? list[0]
return if (!next) return
} await openProject(next.worktree, true)
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0] })
if (!next) return
setState("autoselect", false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
},
),
)
const workspaceName = (directory: string, projectId?: string, branch?: string) => { const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory) const key = workspaceKey(directory)
@@ -620,7 +644,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active return expanded || active
}) })
}) })
@@ -631,7 +655,11 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) const key = workspaceKey(directory)
const project = projects.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (!project) continue if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false) setStore("workspaceExpanded", directory, false)
@@ -683,7 +711,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined, preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
}) })
} }
@@ -696,7 +724,7 @@ export default function Layout(props: ParentProps) {
}) })
createEffect(() => { createEffect(() => {
params.dir route()
globalSDK.url globalSDK.url
prefetchToken.value += 1 prefetchToken.value += 1
@@ -922,6 +950,26 @@ export default function Layout(props: ParentProps) {
navigateToSession(session) navigateToSession(session)
} }
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions() const sessions = currentSessions()
if (sessions.length === 0) return if (sessions.length === 0) return
@@ -988,6 +1036,20 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o", keybind: "mod+o",
onSelect: () => chooseProject(), onSelect: () => chooseProject(),
}, },
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{ {
id: "provider.connect", id: "provider.connect",
title: language.t("command.provider.connect"), title: language.t("command.provider.connect"),
@@ -1156,13 +1218,17 @@ export default function Layout(props: ParentProps) {
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
const key = workspaceKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) .find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (project) return project.worktree if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => root === directory || dirs.includes(directory), ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
) )
if (known) return known[0] if (known) return known[0]
@@ -1178,13 +1244,6 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory) return currentProject()?.worktree ?? projectRoot(directory)
} }
function touchProjectRoute() {
const root = currentProject()?.worktree
if (!root) return
if (server.projects.last() !== root) server.projects.touch(root)
return root
}
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() }) setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root return root
@@ -1293,7 +1352,7 @@ export default function Layout(props: ParentProps) {
function openProject(directory: string, navigate = true) { function openProject(directory: string, navigate = true) {
layout.projects.open(directory) layout.projects.open(directory)
if (navigate) navigateToProject(directory) if (navigate) return navigateToProject(directory)
} }
const handleDeepLinks = (urls: string[]) => { const handleDeepLinks = (urls: string[]) => {
@@ -1348,8 +1407,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const list = layout.projects.list()
const index = list.findIndex((x) => x.worktree === directory) const key = workspaceKey(directory)
const active = currentProject()?.worktree === directory const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key
if (index === -1) return if (index === -1) return
const next = list[index + 1] const next = list[index + 1]
@@ -1684,38 +1744,51 @@ export default function Layout(props: ParentProps) {
const activeRoute = { const activeRoute = {
session: "", session: "",
sessionProject: "", sessionProject: "",
directory: "",
} }
createEffect( createEffect(
on( on(
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, () => {
([ready, dir, id]) => { return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
if (!ready || !dir) { },
([ready, slug, id, root, dir]) => {
if (!ready || !slug || !dir) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const directory = decode64(dir)
if (!directory) return
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) { if (!id) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const session = `${dir}/${id}` const session = `${slug}/${id}`
if (session !== activeRoute.session) {
if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root) activeRoute.directory = dir
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root) activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
}, },
), ),
) )
@@ -1779,8 +1852,13 @@ export default function Layout(props: ParentProps) {
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() const active = currentProject()
const directory = active?.worktree === project.worktree ? currentDir() : undefined const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const extra =
directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
@@ -1903,6 +1981,7 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = { const projectSidebarCtx: ProjectSidebarContext = {
currentDir, currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
@@ -1910,6 +1989,10 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject, navigateToProject,
openSidebar: () => layout.sidebar.open(), openSidebar: () => layout.sidebar.open(),
closeProject, closeProject,
@@ -1932,17 +2015,33 @@ export default function Layout(props: ParentProps) {
setHoverSession, setHoverSession,
} }
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { const SidebarPanel = (panelProps: {
project: Accessor<LocalProject | undefined>
mobile?: boolean
merged?: boolean
}) => {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const item = project()
if (!project) return "" if (!item) return ""
return project.name || getFilename(project.worktree) return item.name || getFilename(item.worktree)
})
const projectId = createMemo(() => project()?.id ?? "")
const worktree = createMemo(() => project()?.worktree ?? "")
const slug = createMemo(() => {
const dir = worktree()
if (!dir) return ""
return base64Encode(dir)
})
const workspaces = createMemo(() => {
const item = project()
if (!item) return [] as string[]
return workspaceIds(item)
}) })
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() => const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
) )
@@ -1951,10 +2050,15 @@ export default function Layout(props: ParentProps) {
.filter((directory) => notification.project.unseenCount(directory) > 0) .filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory)) .forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => { const workspacesEnabled = createMemo(() => {
const project = panelProps.project const item = project()
if (!project) return false if (!item) return false
if (project.vcs !== "git") return false if (item.vcs !== "git") return false
return layout.sidebar.workspaces(project.worktree)() return layout.sidebar.workspaces(item.worktree)()
})
const canToggle = createMemo(() => {
const item = project()
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
}) })
const homedir = createMemo(() => globalSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
@@ -1973,168 +2077,216 @@ export default function Layout(props: ParentProps) {
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}} }}
> >
<Show when={panelProps.project}> <Show
{(p) => ( when={project()}
<> fallback={
<div class="shrink-0 pl-1 py-1"> <Show when={empty()}>
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0"> <div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
<div class="flex flex-col min-w-0"> <div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
<InlineEditor <div class="flex flex-col gap-3">
id={`project:${projectId()}`} <div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
value={projectName} <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
onSave={(next) => renameProject(p(), next)} {language.t("sidebar.empty.description")}
class="text-14-medium text-text-strong truncate" </div>
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div> </div>
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
<DropdownMenu modal={!sidebarHovering()}> {language.t("command.project.open")}
<DropdownMenu.Trigger </Button>
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</div> </div>
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<div class="flex-1 min-h-0 flex flex-col"> <Tooltip
<Show placement="bottom"
when={workspacesEnabled()} gutter={2}
fallback={ value={worktree()}
<> class="shrink-0"
<div class="shrink-0 py-4"> contentStyle={{
<Button "max-width": "640px",
size="large" transform: "translate3d(52px, 0, 0)",
icon="plus-small" }}
class="w-full" >
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)} <span class="text-12-regular text-text-base truncate select-text">
> {worktree().replace(homedir(), "~")}
{language.t("command.session.new")} </span>
</Button> </Tooltip>
</div> </div>
<div class="flex-1 min-h-0">
<LocalWorkspace <DropdownMenu modal={!sidebarHovering()}>
ctx={workspaceSidebarCtx} <DropdownMenu.Trigger
project={p()} as={IconButton}
sortNow={sortNow} icon="dot-grid"
mobile={panelProps.mobile} variant="ghost"
popover={popover()} data-action="project-menu"
/> data-project={slug()}
</div> class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
</> classList={{
} "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
> }}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<> <>
<div class="shrink-0 py-4"> <div class="shrink-0 py-4">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}> <Button
{language.t("workspace.new")} size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button> </Button>
</div> </div>
<div class="relative flex-1 min-h-0"> <div class="flex-1 min-h-0">
<DragDropProvider <LocalWorkspace
onDragStart={handleWorkspaceDragStart} ctx={workspaceSidebarCtx}
onDragEnd={handleWorkspaceDragEnd} project={project()!}
onDragOver={handleWorkspaceDragOver} sortNow={sortNow}
collisionDetector={closestCenter} mobile={panelProps.mobile}
> popover={popover()}
<DragDropSensors /> />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div> </div>
</> </>
</Show> }
</div> >
</> <>
)} <div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show> </Show>
<div <div
@@ -2193,13 +2345,7 @@ export default function Layout(props: ParentProps) {
helpLabel={() => language.t("sidebar.help")} helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => renderPanel={() =>
mobile ? ( mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
<SidebarPanel project={currentProject()} mobile />
) : (
<Show when={currentProject()}>
<SidebarPanel project={currentProject()} merged />
</Show>
)
} }
/> />
) )
@@ -2240,14 +2386,12 @@ export default function Layout(props: ParentProps) {
size={layout.sidebar.width()} size={layout.sidebar.width()}
min={244} min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => { onResize={(w) => {
setState("sizing", true) setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet) if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120) sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w) layout.sidebar.resize(w)
}} }}
onCollapse={layout.sidebar.close}
/> />
</div> </div>
</Show> </Show>
@@ -2300,7 +2444,7 @@ export default function Layout(props: ParentProps) {
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}} }}
> >
<Show when={!autoselecting()} fallback={<div class="size-full" />}> <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children} {props.children}
</Show> </Show>
</main> </main>
@@ -2325,8 +2469,8 @@ export default function Layout(props: ParentProps) {
arm() arm()
}} }}
> >
<Show when={state.peek}> <Show when={peekProject()}>
<SidebarPanel project={state.peek} merged={false} /> <SidebarPanel project={peekProject} merged={false} />
</Show> </Show>
</div> </div>
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => { describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => { test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
}) })
test("preserves posix and drive roots in workspace key", () => { test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/") expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/") expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:\\") expect(workspaceKey("C:\\")).toBe("C:/")
expect(workspaceKey("C:\\\\\\")).toBe("C:\\") expect(workspaceKey("C://")).toBe("C:/")
expect(workspaceKey("C:///")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/")
}) })
+20 -14
View File
@@ -1,11 +1,17 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
type SessionStore = {
session?: Session[]
path: { directory: string }
}
export const workspaceKey = (directory: string) => { export const workspaceKey = (directory: string) => {
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) const value = directory.replaceAll("\\", "/")
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` const drive = value.match(/^([A-Za-z]:)\/+$/)
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" if (drive) return `${drive[1]}/`
return directory.replace(/[\\/]+$/, "") if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
} }
function sortSessions(now: number) { function sortSessions(now: number) {
@@ -25,24 +31,24 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) => const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => const roots = (store: SessionStore) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
stores
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) export const latestRootSession = (stores: SessionStore[], now: number) =>
.sort(sortSessions(now))[0] stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>( export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined> | undefined,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
return Object.values(request).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[] | undefined) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()
for (const session of sessions) { for (const session of sessions ?? []) {
if (!session.parentID) continue if (!session.parentID) continue
const existing = map.get(session.parentID) const existing = map.get(session.parentID)
if (existing) { if (existing) {
+82 -132
View File
@@ -9,14 +9,13 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { agentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
@@ -102,94 +101,46 @@ const SessionRow = (props: {
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
cancelHoverPrefetch: () => void cancelHoverPrefetch: () => void
}): JSX.Element => { }): JSX.Element => (
const [slot, setSlot] = createStore({ <A
open: false, href={`/${props.slug}/session/${props.session.id}`}
show: false, class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
fade: false, onPointerDown={props.warmPress}
}) onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
let f: number | undefined onFocus={props.warmFocus}
const clear = () => { onClick={() => {
if (f !== undefined) window.clearTimeout(f) props.setHoverSession(undefined)
f = undefined if (props.sidebarOpened()) return
} props.clearHoverProjectSoon()
}}
onCleanup(clear) >
createEffect( <div class="flex items-center gap-1 w-full">
on( <div
() => props.isWorking(), class="shrink-0 size-6 flex items-center justify-center"
(on, prev) => { style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
clear() >
if (on) { <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
setSlot({ open: true, show: true, fade: false }) <Match when={props.isWorking()}>
return <Spinner class="size-[15px]" />
} </Match>
if (prev) { <Match when={props.hasPermissions()}>
setSlot({ open: false, show: true, fade: true }) <div class="size-1.5 rounded-full bg-surface-warning-strong" />
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) </Match>
return <Match when={props.hasError()}>
} <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
setSlot({ open: false, show: false, fade: false }) </Match>
}, <Match when={props.unseenCount() > 0}>
{ defer: true }, <div class="size-1.5 rounded-full bg-text-interactive-base" />
), </Match>
) </Switch>
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`relative flex items-center min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<Show when={!props.isWorking() && (props.hasPermissions() || props.hasError() || props.unseenCount() > 0)}>
<div
classList={{
"absolute left-0 top-1/2 -translate-y-1/2 size-1.5 rounded-full": true,
"bg-surface-warning-strong": props.hasPermissions(),
"bg-text-diff-delete-base": !props.hasPermissions() && props.hasError(),
"bg-text-interactive-base": !props.hasPermissions() && !props.hasError() && props.unseenCount() > 0,
}}
aria-hidden="true"
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div> </div>
</A> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
) {props.session.title}
} </span>
</div>
</A>
)
const SessionHoverPreview = (props: { const SessionHoverPreview = (props: {
mobile?: boolean mobile?: boolean
@@ -206,34 +157,45 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void onMessageSelect: (message: Message) => void
trigger: JSX.Element trigger: JSX.Element
}): JSX.Element => ( }): JSX.Element => {
<HoverCard let ref: HTMLDivElement | undefined
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0} return (
placement="right-start" <HoverCard
gutter={16} openDelay={1000}
shift={-2} closeDelay={props.sidebarHovering() ? 600 : 0}
trigger={props.trigger} placement="right-start"
open={props.hoverSession() === props.session.id} gutter={16}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} shift={-2}
> trigger={<div ref={ref}>{props.trigger}</div>}
<Show open={props.hoverSession() === props.session.id}
when={props.hoverReady()} onOpenChange={(open) => {
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
> >
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full"> <Show
<MessageNav when={props.hoverReady()}
messages={props.hoverMessages() ?? []} fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
current={undefined} >
getLabel={props.messageLabel} <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
onMessageSelect={props.onMessageSelect} <MessageNav
size="normal" messages={props.hoverMessages() ?? []}
class="w-60" current={undefined}
/> getLabel={props.messageLabel}
</div> onMessageSelect={props.onMessageSelect}
</Show> size="normal"
</HoverCard> class="w-60"
) />
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
@@ -268,19 +230,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const tint = createMemo(() => { const tint = createMemo(() => {
const messages = sessionStore.message[props.session.id] return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
if (!messages) return undefined
let user: Message | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "user") continue
user = message
break
}
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agentColor(user.agent, agent?.color)
}) })
const hoverMessages = createMemo(() => const hoverMessages = createMemo(() =>
@@ -359,7 +309,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-3 pr-3 transition-colors class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Show <Show
@@ -445,7 +395,7 @@ export const NewSessionItem = (props: {
> >
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center"> <div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="plus-small" size="small" class="text-icon-weak" /> <Icon name="new-session" size="small" class="text-icon-weak" />
</div> </div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label} {label}
@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
@@ -22,6 +23,7 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void navigateToProject: (directory: string) => void
openSidebar: () => void openSidebar: () => void
closeProject: (directory: string) => void closeProject: (directory: string) => void
@@ -108,8 +110,14 @@ const ProjectTile = (props: {
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true) props.setSuppressHover(true)
event.preventDefault() event.preventDefault()
}} }}
@@ -129,12 +137,11 @@ const ProjectTile = (props: {
props.onProjectFocus(props.project.worktree) props.onProjectFocus(props.project.worktree)
}} }}
onClick={() => { onClick={() => {
props.setOpen(false)
if (props.selected()) { if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle() layout.sidebar.toggle()
return return
} }
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree) props.navigateToProject(props.project.worktree)
}} }}
onBlur={() => props.setOpen(false)} onBlur={() => props.setOpen(false)}
@@ -191,7 +198,6 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>> projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]> workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -258,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => { onClick={() => {
props.ctx.openSidebar() props.ctx.openSidebar()
props.setOpen(false) props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree) props.ctx.navigateToProject(props.project.worktree)
}} }}
@@ -278,37 +284,21 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const selected = createMemo( const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({ const [state, setState] = createStore({
open: false,
menu: false, menu: false,
suppressHover: false, suppressHover: false,
}) })
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo( const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
createEffect(() => { const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const label = (directory: string) => { const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
@@ -349,7 +339,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled} workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject} closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)} setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)} setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)} setSuppressHover={(value) => setState("suppressHover", value)}
language={language} language={language}
/> />
@@ -360,7 +350,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}> <Show when={preview() && !selected()} fallback={tile()}>
<HoverCard <HoverCard
open={!state.suppressHover && state.open && !state.menu} open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0} openDelay={0}
closeDelay={0} closeDelay={0}
placement="right-start" placement="right-start"
@@ -369,7 +359,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => { onOpenChange={(value) => {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
setState("open", value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined) if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
@@ -384,7 +374,6 @@ export const SortableProject = (props: {
projectChildren={projectChildren} projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren} workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions } from "./helpers" import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -217,7 +217,7 @@ const WorkspaceActions = (props: {
<Show when={!props.touch()}> <Show when={!props.touch()}>
<Tooltip value={props.language.t("command.session.new")} placement="top"> <Tooltip value={props.language.t("command.session.new")} placement="top">
<IconButton <IconButton
icon="plus-small" icon="new-session"
variant="ghost" variant="ghost"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session" data-action="workspace-new-session"
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session)) const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => props.ctx.currentDir() === props.directory) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory) const name = branch ?? getFilename(props.directory)
@@ -332,12 +332,13 @@ export const SortableWorkspace = (props: {
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active()) const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const busy = createMemo(() => props.ctx.isBusy(props.directory)) const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false) const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)") const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory) await globalSync.project.loadSessions(props.directory)
@@ -472,8 +473,9 @@ export const LocalWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session)) const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const loading = createMemo(() => !booted() && sessions().length === 0) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) const loading = createMemo(() => !booted() && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
+134 -127
View File
@@ -1,5 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { import {
batch, batch,
onCleanup, onCleanup,
@@ -44,7 +45,7 @@ import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalByI
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
@@ -327,10 +328,7 @@ export default function Page() {
}) })
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -490,7 +488,7 @@ export default function Page() {
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
if (!next.id) resetSessionModel(local) if (prev.id && !next.id) local.session.reset()
}, },
{ defer: true }, { defer: true },
), ),
@@ -506,7 +504,6 @@ export default function Page() {
const [followup, setFollowup] = createStore({ const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>, failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>, paused: {} as Record<string, boolean | undefined>,
edit: {} as Record< edit: {} as Record<
@@ -644,25 +641,24 @@ export default function Page() {
globalSync.set("project", [...list, next]) globalSync.set("project", [...list, next])
} }
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))
function initGit() { function initGit() {
if (ui.git) return if (gitMutation.isPending) return
setUi("git", true) gitMutation.mutate()
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
} }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -961,8 +957,8 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")} {language.t("session.review.noVcs.createGit.description")}
</div> </div>
</div> </div>
<Button size="large" disabled={ui.git} onClick={initGit}> <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{ui.git {gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading") ? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")} : language.t("session.review.noVcs.createGit.action")}
</Button> </Button>
@@ -1379,10 +1375,40 @@ export default function Page() {
return followup.edit[id] return followup.edit[id]
}) })
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
return false
})
if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) resumeScroll()
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => { const sendingFollowup = createMemo(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
return followup.sending[id] if (!followupBusy(id)) return
return followupMutation.variables?.id
}) })
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
@@ -1422,37 +1448,15 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followup.sending[sessionID]) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
if (opts?.manual) setFollowup("paused", sessionID, undefined) return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
} }
const editFollowup = (id: string) => { const editFollowup = (id: string) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
const item = queuedFollowups().find((entry) => entry.id === id) const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return if (!item) return
@@ -1475,8 +1479,77 @@ export default function Page() {
const halt = (sessionID: string) => const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => { const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID) const value = draft(input.messageID)
const dir = base64Encode(sdk.directory)
return sdk.client.session return sdk.client.session
.fork(input) .fork(input)
.then((result) => { .then((result) => {
@@ -1488,86 +1561,20 @@ export default function Page() {
}) })
return return
} }
navigate(`/${base64Encode(sdk.directory)}/session/${next.id}`) prompt.set(value, undefined, { dir, id: next.id })
requestAnimationFrame(() => { navigate(`/${dir}/session/${next.id}`)
prompt.set(value)
})
}) })
.catch(fail) .catch(fail)
} }
const revert = (input: { sessionID: string; messageID: string }) => { const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return if (reverting()) return
const prev = prompt.current().slice() return revertMutation.mutateAsync(input)
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
} }
const restore = (id: string) => { const restore = (id: string) => {
const sessionID = params.id if (!params.id || reverting()) return
if (!sessionID || ui.restoring || ui.reverting) return return restoreMutation.mutateAsync(id)
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
} }
const rolled = createMemo(() => { const rolled = createMemo(() => {
@@ -1586,7 +1593,7 @@ export default function Page() {
const item = queuedFollowups()[0] const item = queuedFollowups()[0]
if (!item) return if (!item) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (composer.blocked()) return if (composer.blocked()) return
@@ -1781,8 +1788,8 @@ export default function Page() {
rolled().length > 0 rolled().length > 0
? { ? {
items: rolled(), items: rolled(),
restoring: ui.restoring, restoring: restoring(),
disabled: ui.reverting, disabled: reverting(),
onRestore: restore, onRestore: restore,
} }
: undefined : undefined
@@ -1,5 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
sending: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const reply = async (answers: QuestionAnswer[]) => { const replyMutation = useMutation(() => ({
if (store.sending) return mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit() props.onSubmit()
setStore("sending", true) },
try { onSuccess: () => {
await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true replied = true
cache.delete(props.request.id) cache.delete(props.request.id)
} catch (err) { },
fail(err) onError: fail,
} finally { }))
setStore("sending", false)
} const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
await replyMutation.mutateAsync(answers)
} }
const reject = async () => { const reject = async () => {
if (store.sending) return if (sending()) return
await rejectMutation.mutateAsync()
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
} }
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customToggle = () => { const customToggle = () => {
if (store.sending) return if (sending()) return
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customOpen = () => { const customOpen = () => {
if (store.sending) return if (sending()) return
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (store.sending) return if (sending()) return
if (optIndex === options().length) { if (optIndex === options().length) {
customOpen() customOpen()
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const next = () => { const next = () => {
if (store.sending) return if (sending()) return
if (store.editing) commitCustom() if (store.editing) commitCustom()
if (store.tab >= total() - 1) { if (store.tab >= total() - 1) {
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const back = () => { const back = () => {
if (store.sending) return if (sending()) return
if (store.tab <= 0) return if (store.tab <= 0) return
setStore("tab", store.tab - 1) setStore("tab", store.tab - 1)
setStore("editing", false) setStore("editing", false)
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (store.sending) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.answers[i()]?.length ?? 0) > 0 || (store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
} }
disabled={store.sending} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/> />
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
<Show when={store.tab > 0}> <Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}> <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}> <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={picked()} data-picked={picked()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={picked()} aria-checked={picked()}
disabled={store.sending} disabled={sending()}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
> >
<span data-slot="question-option-check" aria-hidden="true"> <span data-slot="question-option-check" aria-hidden="true">
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={store.sending} disabled={sending()}
onClick={customOpen} onClick={customOpen}
> >
<span <span
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
onMouseDown={(e) => { onMouseDown={(e) => {
if (store.sending) { if (sending()) {
e.preventDefault() e.preventDefault()
return return
} }
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
placeholder={language.t("ui.question.custom.placeholder")} placeholder={language.t("ui.question.custom.placeholder")}
value={input()} value={input()}
rows={1} rows={1}
disabled={store.sending} disabled={sending()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()

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