Compare commits

...

866 Commits

Author SHA1 Message Date
Aiden Cline 78829463ec chore: remove dead code for todoread tool (#19128)
publish-github-action / publish (push) Waiting to run
2026-03-25 16:21:42 +00:00
Adam 1a233e5928 chore: cleanup 2026-03-25 11:15:34 -05:00
opencode-agent[bot] dc3ca3bd3d chore: update nix node_modules hashes 2026-03-25 16:08:30 +00:00
opencode-agent[bot] ef5b7f07b5 chore: generate 2026-03-25 15:40:53 +00:00
Shoubhit Dash 973ad436e1 feat: restore git-backed review modes with effectful git service (#18900) 2026-03-25 21:09:53 +05:30
Adam 4a7091c988 chore: storybook tweaks 2026-03-25 10:20:19 -05:00
Aiden Cline 8b0654dbd5 chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064) 2026-03-25 10:00:39 -05:00
opencode-agent[bot] a53518d64b chore: generate 2026-03-25 14:48:44 +00:00
James Long 87d41cd5ac feat(core): initial implementation of syncing (#17814) 2026-03-25 10:47:40 -04:00
Adam 04da047809 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam 0684cdc468 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam 00c9fd819e fix(app): opencode web server url 2026-03-25 06:41:00 -05:00
Adam 870a2f8773 Reapply "fix(app): startup efficiency"
This reverts commit 24f93b627b.
2026-03-25 06:25:57 -05:00
Adam 24f93b627b Revert "fix(app): startup efficiency" 2026-03-25 06:25:05 -05:00
Adam 7957a49217 fix(app): hash inline script for csp 2026-03-25 05:59:06 -05:00
Adam c21949be00 Reapply "fix(app): more startup efficiency (#18985)"
This reverts commit e514bfd49e.
2026-03-25 05:59:06 -05:00
Adam eba2f67227 Reapply "fix(app): startup efficiency (#18854)"
This reverts commit 311a58a8c8.
2026-03-25 05:59:05 -05:00
Shoubhit Dash 66444a700a fix(app): move message navigation off cmd+arrow (#18728) 2026-03-25 05:24:55 -05:00
Brendan Allan 42b1d9f9eb app: pre-warm project globalSync state when navigate project via keybind (#19088) 2026-03-25 17:32:49 +08:00
Brendan Allan d5b2a6ac72 electron: remove file extension from electron-store wrapper (#19082) 2026-03-25 07:57:27 +00:00
Brendan Allan 0f8d7f1f5c electron: add createDirectory to open directory picker (#19071) 2026-03-25 06:25:51 +00:00
Luke Parker 81c4d85eb7 fix: unblock beta conflict recovery (#19068) 2026-03-25 06:14:38 +00:00
Luke Parker 8a987ee885 fix: beta resolver typecheck + build smoke check (#19060) 2026-03-25 05:45:30 +00:00
Aiden Cline bba2963719 tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) 2026-03-25 00:31:29 -05:00
Luke Parker 110c3bd372 fix: provide merge context to beta conflict resolver (#19055) 2026-03-25 04:45:37 +00:00
Dax 771508a2d4 fix(opencode): avoid snapshotting files over 2MB (#19043) 2026-03-25 04:43:48 +00:00
Dax Raad d01a4e4d28 Change issue close reason from not_planned to completed 2026-03-24 23:55:10 -04:00
Dax Raad 468eec0ed8 Process issues sequentially to avoid rate limits 2026-03-24 23:54:27 -04:00
Dax Raad ede2f19985 Fix close-issues workflow permissions
- Add contents: read permission for checkout
- Use github.token instead of secrets.GITHUB_TOKEN
2026-03-24 23:51:46 -04:00
Dax Raad 32c5aabdb9 Add close-issues script and GitHub Action
- Create script/github/close-issues.ts to close stale issues after 60 days
- Add GitHub Action workflow to run daily at 2 AM
- Remove old stale-issues workflow to avoid conflicts
2026-03-24 23:50:35 -04:00
Dax Raad 14d65e9c2b fix: increase operations-per-run to 1000 and pin stale action to v10.2.0
The stale-issues workflow was hitting the default 30 operations limit,
preventing it from processing all 2900+ issues/PRs. Increased to 1000
to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility.
2026-03-24 23:38:15 -04:00
Kit Langton 8610313b6a effectify Worktree service (#18679) 2026-03-24 20:26:21 -04:00
Adam 311a58a8c8 Revert "fix(app): startup efficiency (#18854)"
This reverts commit 824d903e86.
2026-03-24 18:36:37 -05:00
Adam e514bfd49e Revert "fix(app): more startup efficiency (#18985)"
This reverts commit dcca493ece.
2026-03-24 18:36:25 -05:00
Kit Langton 57bc933abd fix(opencode): skip typechecking generated models snapshot (#19018) 2026-03-24 19:11:45 -04:00
opencode 84de2c4832 release: v1.3.2 2026-03-24 22:50:35 +00:00
Dax Raad 268a777129 change model for changelog 2026-03-24 18:25:52 -04:00
Dax Raad ff043c204a ci 2026-03-24 18:24:34 -04:00
Dax 16a38009ee feat(tui): add heap snapshot functionality for TUI and server (#19028) 2026-03-24 18:20:11 -04:00
opencode-agent[bot] e426ed3e9a chore: update nix node_modules hashes 2026-03-24 22:07:32 +00:00
Jay V 1d7e22d716 tweak: use theme tokens for debug bar surface 2026-03-24 22:07:32 +00:00
opencode 1158f4f6a5 release: v1.3.1 2026-03-24 22:07:24 +00:00
Dax 50ac413272 fix(core): restore SIGHUP exit handler (#16057) (#18527) 2026-03-24 17:42:58 -04:00
Kamil Jopek d858439803 feat: add Poe OAuth auth plugin (#18477) 2026-03-24 16:17:47 -05:00
Adam fbac9617ff fix(app): session timeline jumping on scroll (#18993) 2026-03-24 13:51:09 -05:00
opencode-agent[bot] c2c4a476ef chore: generate 2026-03-24 18:43:20 +00:00
Nicholas Hansen 948aad44c8 feat(bedrock): Add token caching for any amazon-bedrock provider (#18959) 2026-03-24 13:42:20 -05:00
opencode-agent[bot] 235f8771bf chore: update nix node_modules hashes 2026-03-24 18:40:28 +00:00
Adam 22e7516a47 fix(app): sidebar ux 2026-03-24 13:35:20 -05:00
Adam dcca493ece fix(app): more startup efficiency (#18985) 2026-03-24 13:23:41 -05:00
Aiden Cline 67a30121a9 chore: effectify agent.ts (#18971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-03-24 18:15:23 +00:00
Caleb Norton 2465e8d47d fix: nix hash update parsing... again (#18989) 2026-03-24 13:06:46 -05:00
opencode-agent[bot] 156f7fdb1a chore: generate 2026-03-24 18:05:43 +00:00
Adam 70289b7236 fix(app): sidebar truncation 2026-03-24 13:04:32 -05:00
Kit Langton a6ed355ed1 effectify Project service (#18808) 2026-03-24 14:04:22 -04:00
Ryan Skidmore fbbd8c5ee8 fix: improve plugin system robustness — agent/command resolution, async errors, hook timing, two-phase init (#18280)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:55 -05:00
Caleb Norton 0299ab17fa chore: update flake.lock (#18976)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:25 -05:00
Vladimir Glafirov c5e80e7454 fix: route GitLab Duo Workflow system prompt via flowConfig (#18928) 2026-03-24 12:33:18 -05:00
Caleb Norton 2a10f0d418 fix: nix hash update parsing (#18979) 2026-03-24 12:32:48 -05:00
opencode-agent[bot] d68d4ef387 chore: generate 2026-03-24 17:05:02 +00:00
Kit Langton 9519186313 fix: remove flaky cross-spawn spawner tests (#18977) 2026-03-24 13:04:04 -04:00
Jack fb1f1b24fb fix: update Feishu community links for zh locales (#18975) 2026-03-25 01:03:01 +08:00
opencode-agent[bot] a83ebab843 chore: update nix node_modules hashes 2026-03-24 16:43:24 +00:00
Caleb Norton 973103d552 fix: better nix hash detection (#18957) 2026-03-24 11:30:39 -05:00
Kit Langton 7fcc8d6ad5 fix: restore cross-spawn behavior for effect child processes (#18798) 2026-03-24 10:35:24 -04:00
Adam 824d903e86 fix(app): startup efficiency (#18854) 2026-03-24 09:10:24 -05:00
Burak Yigit Kaya cde5e4b899 fix(ui): eliminate N+1 reactive subscriptions in SessionTurn (#18924) 2026-03-24 09:02:22 -05:00
Burak Yigit Kaya 035f9d9e6d fix(app): use optional chaining for model.current() in ProviderIcon (#18927) 2026-03-24 09:01:58 -05:00
Burak Yigit Kaya 32e89036ec fix(app): filter non-renderable part types from browser store (#18926) 2026-03-24 09:01:25 -05:00
Shoubhit Dash 751bc0a117 fix(app): stop terminal autofocus on shortcuts (#18931) 2026-03-24 11:16:16 +00:00
Sebastian 62b3869f0d manually lock/unlock theme mode (#18905) 2026-03-24 10:00:19 +01:00
Frank 4d24f97b2f sync 2026-03-24 01:00:20 -04:00
opencode-agent[bot] bc2cdbc319 chore: update nix node_modules hashes 2026-03-24 03:14:22 +00:00
Vladimir Glafirov abc6d9d2ca chore: bump gitlab-ai-provider to 5.3.1 for GPT-5.4 model support (#18849) 2026-03-23 22:00:36 -05:00
Frank f6e8d78e6d wip: zen 2026-03-23 22:24:58 -04:00
Frank 476da1afbf Zen: adjust cache tokens 2026-03-23 20:33:11 -04:00
Sebastian 4a96259b39 use renderer theme mode to switch dark/light mode (#18851) 2026-03-24 00:32:48 +01:00
opencode-agent[bot] ffa297cdeb chore: generate 2026-03-23 17:12:55 +00:00
Kit Langton 9530074d34 fix console account URL handling (#18809) 2026-03-23 13:11:38 -04:00
Frank 39c617e0a7 ci: fix 2026-03-23 12:47:42 -04:00
Aiden Cline 3484d75370 tweak: add back setting user agent in requests (#18795) 2026-03-23 15:34:59 +00:00
Abhishek Keshri 1dc24d4b38 feat(tui): add syntax highlighting for kotlin, hcl, lua, toml (#18198) 2026-03-23 16:15:24 +01:00
David Hill 72835aee86 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan 1bcf0e8e34 fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot] 131cca1fcd chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot] 988f548fc9 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot] 32be4661d5 chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan bda7879e25 fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan 21ed03a5dc fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan e457633e93 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash ac936c9ee0 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash 23d37d87dc fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash 7cc9d15cc6 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot] 7de12e7f29 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker 440a7693f4 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long fa54dd86f7 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot] 14c98756df chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian 3ab06799e4 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill 4e9cf57b60 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker 233dd46542 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot] b42f24b16d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill c9c025d3b9 tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David Hill c606f956f8 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 d56481b2d1 chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
Dax Raad 08da7fd04b changelog slash command 2026-03-22 19:45:23 -04:00
opencode-agent[bot] c7d639bea8 chore: generate 2026-03-22 23:32:09 +00:00
opencode 4e248b3830 release: v1.3.0 2026-03-22 23:32:01 +00:00
Dax a65e6119ad feat: interactive update flow for non-patch releases (#18662) 2026-03-22 23:12:40 +00:00
David Hill e765ccb0e3 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
Filip ce6f9bccb4 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 36f76fdf16 effectify Pty service (#18572) 2026-03-22 01:17:13 +00:00
Kit Langton 22d942cba2 effectify ToolRegistry service (#18571) 2026-03-22 01:06:01 +00:00
opencode-agent[bot] 919ab3e898 chore: generate 2026-03-22 00:51:19 +00:00
Kit Langton 6fcdb273eb effectify Plugin service (#18570) 2026-03-22 00:50:22 +00:00
Kit Langton 65d4fcaf4e effectify Command service (#18568) 2026-03-22 00:21:41 +00:00
Kit Langton ccbd2936d2 effectify SessionStatus service (#18565) 2026-03-21 23:55:07 +00:00
opencode-agent[bot] 05e7b6e5a8 chore: update nix node_modules hashes 2026-03-21 19:17:12 +00:00
Sebastian f9fd9c27bd Upgrade opentui to 0.1.88 (#18079) 2026-03-21 19:00:42 +00:00
Protocol Zero 73bff070e1 fix(provider): only set thinkingConfig for models with reasoning capability (#18283) 2026-03-21 11:57:52 -05:00
opencode-agent[bot] d234f92039 chore: update nix node_modules hashes 2026-03-21 15:47:15 +00:00
opencode-agent[bot] 80342b9679 chore: generate 2026-03-21 15:34:05 +00:00
Brendan Allan 34f9f99462 app: manage mutation loading states with tanstack query (#18501) 2026-03-21 23:33:04 +08:00
Brendan Allan 9a68318aaa app: allow navigating projects with keybinds (#18502) 2026-03-21 22:13:09 +08:00
opencode-agent[bot] 69bbc9f1cd chore: generate 2026-03-21 13:12:54 +00:00
Dax Raad 2c47881bdf fix annotation 2026-03-21 09:11:15 -04:00
Frank a42b2a0ddb wip: zen 2026-03-21 04:07:51 -04:00
opencode-agent[bot] fd9a27ae04 chore: generate 2026-03-21 04:52:23 +00:00
Kit Langton b6aa472839 Move service state into InstanceState, flatten service facades (#18483) 2026-03-21 04:51:35 +00:00
opencode-agent[bot] 2a81d70eb0 chore: generate 2026-03-21 03:11:28 +00:00
Kit Langton 45a3b2ca05 fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) 2026-03-20 23:10:34 -04:00
Dax Raad cb807850b5 allow customizing DB location 2026-03-20 22:49:55 -04:00
Aiden Cline 71ccf72898 ignore: add danieljoshuanazareth to disavow list (#18476) 2026-03-20 21:34:07 -04:00
github-actions[bot] 1477804b1c Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628
2026-03-21 01:28:40 +00:00
Dax Raad d2273f3e88 ci 2026-03-20 17:00:05 -04:00
Kit Langton 9eafdc92ec fix: switch consumers to service imports to break bundle cycles (#18438) 2026-03-20 20:55:46 +00:00
Dax Raad f891192f86 commit and push 2026-03-20 16:36:19 -04:00
Dax Raad 1953f5cfa5 fix question cross out 2026-03-20 15:50:04 -04:00
Kit Langton 0457745593 fix: update stale account url/email on re-login (#18426) 2026-03-20 14:50:01 -04:00
opencode-agent[bot] 8dcdcdad90 chore: generate 2026-03-20 18:38:08 +00:00
Kit Langton 42a5ceb048 fix: apply Layer.fresh at instance service definition site (#18418) 2026-03-20 14:37:12 -04:00
opencode-agent[bot] cef5ab2e54 chore: update nix node_modules hashes 2026-03-20 18:16:17 +00:00
Vladimir Glafirov 1fb2ef5ec2 feat: enable GitLab Agent Platform with workflow model discovery (#18014) 2026-03-20 12:55:22 -05:00
opencode-agent[bot] d79132c5c1 chore: generate 2026-03-20 16:11:21 +00:00
Kit Langton c71771dcf2 effectify Installation service, drop Effect suffix from namespaces (#18266) 2026-03-20 12:10:33 -04:00
Aiden Cline 371aca7729 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
MC bf916a653c 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 bec6f54109 app: file type filter on desktop + multiple files (#18403) 2026-03-20 15:02:07 +00:00
Shoubhit Dash 884e6c4b26 fix(app): align review file comments with diff comments (#18406) 2026-03-20 14:44:18 +00:00
Luke Parker 4ccb32aca8 deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot] 205df45a9b chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan 6c7d5eaaab patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker 763c116502 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke Parker ebbb4124b8 fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot] 16a0abc970 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax f07cdc7072 fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
Jaaneek 5126f77686 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] 6875f2bad8 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax 8158f21835 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Dax ce01d92ef8 fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax ff945d8632 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax ed094f2196 refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax fb1b5328d7 refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank 8570353f0c doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax 0848f630b2 chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9d48e1e13c go: upi translation 2026-03-19 18:54:32 -04:00
Dax 8ecde3ae5f refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank 7a43e617fb go: upi payment 2026-03-19 18:44:24 -04:00
opencode-agent[bot] 922a8abb7c chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton c6bc3028a6 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot] aead63e1db chore: generate 2026-03-19 20:05:33 +00:00
jorge g 8fa53ae5a5 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 b9929cc644 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long 159096c4d5 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] ccd4d75ba4 chore: generate 2026-03-19 17:52:50 +00:00
James Long 32a14ba2ea fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
opencode-agent[bot] d2beddb249 chore: generate 2026-03-19 16:16:29 +00:00
Aiden Cline d210d67f1e ignore: revert 3 commits that broke dev branch (#18260) 2026-03-19 11:15:07 -05:00
Kit Langton 35dffce18d fix forked prompt attachments losing file parts (#17815) 2026-03-19 12:00:32 -04:00
Shoubhit Dash 7d4b36643a fix(app): show review on the empty session route (#18251) 2026-03-19 19:51:32 +05:30
opencode-agent[bot] b32c51b146 chore: generate 2026-03-19 14:00:32 +00:00
Shoubhit Dash 1725f7bb2e feat: add git-backed session review modes (#17961) 2026-03-19 19:29:29 +05:30
Brendan Allan c3ec0622de 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 ec90203990 electron: lazily read updater enabled 2026-03-19 21:47:47 +08:00
OpeOginni 6d61ebeff8 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew Maguire 50ccbaa571 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] afdc427871 chore: generate 2026-03-19 04:46:39 +00:00
Dax a84103459a anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline 6a9ff35baf feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
Kit Langton ed45bd6de1 fix(session): preserve tagged error messages (#18165) 2026-03-18 20:36:53 -04:00
Frank fd5599bd87 zen: add mimo pro/omni models 2026-03-18 20:28:41 -04:00
opencode-agent[bot] a241f5ae26 chore: update nix node_modules hashes 2026-03-19 00:08:27 +00:00
opencode-agent[bot] a65004283f chore: update nix node_modules hashes 2026-03-19 00:07:30 +00:00
Luke Parker fcedc54b02 fix: restore recent test regressions and upgrade effect beta (#18158) 2026-03-19 09:54:01 +10:00
Kit Langton c325ac5b5b feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138) 2026-03-18 19:52:43 -04:00
opencode-agent[bot] a8274af78b chore: generate 2026-03-18 23:45:03 +00:00
Kit Langton ae15341ffe chore: type Provider.list() as Record<ProviderID, Info>, delete dead code (#18123) 2026-03-18 19:43:12 -04:00
opencode-agent[bot] 2e4326bb36 chore: update nix node_modules hashes 2026-03-18 23:03:55 +00:00
Luke Parker 2728950929 fix(windows): use cross-spawn for shim-backed commands (#18010) 2026-03-19 08:49:16 +10:00
Aiden Cline 6d979838b6 tweak: remove unnecessary parts from the fallback system prompt (#18140) 2026-03-18 17:27:47 -05:00
Frank dd43bafeb0 docs: update go models 2026-03-18 17:48:39 -04:00
Frank 8cdff61352 fix: docs 2026-03-18 16:30:33 -04:00
Frank b8795208f0 zen: minimax m2.7 2026-03-18 14:30:55 -04:00
Frank 651e672db3 zen: minimax m2.7 2026-03-18 14:15:00 -04:00
opencode-agent[bot] 4af0e6d5d2 chore: generate 2026-03-18 17:37:40 +00:00
Kit Langton 05f98c0e42 refactor(effect): unify service namespaces and align naming (#18093) 2026-03-18 13:34:36 -04:00
Aiden Cline 5ece9ea6a1 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 3a89645257 tweak: update gpt subscription model list (#18101) 2026-03-18 10:51:39 -05:00
Frank 5c21abc6c4 zen: fix routing non OC traffic 2026-03-18 10:59:56 -04:00
OpeOginni 55cb0a2ab0 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 2952da2736 tweak(ui): theme overrides (#17958) 2026-03-18 19:26:32 +05:30
David Hill 40792c96c7 tui: make the title bar search easier to scan without a redundant icon 2026-03-18 13:52:57 +00:00
David Hill ab8bd02a48 Revert "tui: clean up search button in session header by removing magnifying glass icon and excess padding"
This reverts commit b22cc3eac2.
2026-03-18 13:52:36 +00:00
David Hill b22cc3eac2 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] 486e797f81 chore: generate 2026-03-18 12:58:46 +00:00
OpeOginni 2499e69b88 docs(cli): update experimental TY LSP flag description for clarity across multiple languages (#14770) 2026-03-18 18:27:42 +05:30
Brendan Allan 3a1f2f7905 app: remove debug text 2026-03-18 17:02:23 +08:00
Brendan Allan d8199b91d7 app: replace autoselect effects with single resource 2026-03-18 17:01:38 +08:00
Brendan Allan a1353899bc app: initialize command catalog more efficiently
cuts down load times by like 30-50%
2026-03-18 14:46:53 +08:00
Frank b5503f1c86 zen: gpt 5.4 mini and nano 2026-03-18 02:12:30 -04:00
Johannes Loher ca7cb89cb0 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 5cc809edcd wip: zen 2026-03-17 23:31:14 -04:00
Frank d4c69eec62 wip: zen 2026-03-17 22:50:54 -04:00
Ryan Vogel 23791c6657 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] 855a0d72a2 chore: generate 2026-03-18 02:01:02 +00:00
Kit Langton 2a6909cd07 refactor(truncation): effectify TruncateService, delete Scheduler (#17957) 2026-03-17 21:59:54 -04:00
Ariane Emory 8f3cb8b609 fix: Add apply_patch to EDIT_TOOLS filter (#18009) 2026-03-17 20:11:42 -05:00
opencode-agent[bot] 4cee904375 chore: generate 2026-03-18 01:05:16 +00:00
Kit Langton 0782c757be refactor(snapshot): effectify SnapshotService (#17878) 2026-03-17 21:04:16 -04:00
Kit Langton 90f66d356d add prompt schema validation debug logs (#17812) 2026-03-17 19:18:16 -04:00
Frank 7afb3e3de5 wip: zen 2026-03-17 19:06:22 -04:00
Frank edd365a417 zen: add missing model lab names 2026-03-17 18:41:38 -04:00
Frank ea856eb472 zen: error logging 2026-03-17 16:53:10 -04:00
David Hill 96ecc010bc 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 eca94bcba5 wip: zen 2026-03-17 15:00:44 -04:00
Frank 7cb5dc27eb zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni a5f0b7be4e docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash b21adc16ee fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
Brendan Allan 728d8c72df app: inherit owner when creating prompt session 2026-03-17 19:47:06 +08:00
opencode-agent[bot] 8e8276dfca chore: generate 2026-03-17 11:15:35 +00:00
Luke Parker 1ed580cef1 fix(app): avoid prompt tooltip Switch on startup (#17857) 2026-03-17 06:14:30 -05:00
opencode-agent[bot] 666ad5b9b2 chore: generate 2026-03-17 03:47:33 +00:00
Kyle Altendorf 8d8c2d645c 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 2dd6e169a1 chore: denounce ai spammer (#17901) 2026-03-16 22:38:15 -05:00
Luke Parker 80358a80f7 test(app): deflake slash terminal toggle flow (#17881) 2026-03-17 12:55:58 +10:00
Luke Parker 1a7c45c770 test(opencode): deflake file and tool timing (#17859) 2026-03-17 00:49:04 +00:00
Kyle Altendorf 31f5c3eb93 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] 47e83d74a9 chore: update nix node_modules hashes 2026-03-17 00:05:19 +00:00
opencode-agent[bot] 9cdef4e301 chore: update nix node_modules hashes 2026-03-16 23:54:30 +00:00
opencode-agent[bot] 508f5def47 chore: generate 2026-03-16 23:52:10 +00:00
Kit Langton cbb2e9b0f4 refactor(skill): effectify SkillService as scoped service (#17849) 2026-03-16 23:51:07 +00:00
AbigailJixiangyuyu 4c056c026d fix(windows): restore /editor support on Windows (#17146) 2026-03-17 08:11:02 +10:00
Aiden Cline fb33a71539 fix: github copilot enterprise integration (#17847) 2026-03-16 17:05:14 -05:00
opencode-agent[bot] 7256de81d3 chore: generate 2026-03-16 20:19:50 +00:00
Kit Langton 3355d71a20 refactor(file): effectify FileService as scoped service (#17845) 2026-03-16 16:18:39 -04:00
opencode-agent[bot] 036f3dc22e chore: generate 2026-03-16 19:59:49 +00:00
Kit Langton c3de254735 refactor(format): effectify FormatService as scoped service (#17675) 2026-03-16 15:58:36 -04:00
opencode-agent[bot] febd77140d chore: generate 2026-03-16 18:24:21 +00:00
Kit Langton 184fc0b796 refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) 2026-03-16 18:23:13 +00:00
opencode-agent[bot] 3831fdaac4 chore: generate 2026-03-16 18:00:18 +00:00
Kit Langton 9edca88956 fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) 2026-03-16 13:59:11 -04:00
opencode-agent[bot] 387cb0d2d2 chore: generate 2026-03-16 17:19:44 +00:00
Kit Langton 6696517665 stack: effectify-file-watcher-service (#17827) 2026-03-16 13:18:40 -04:00
opencode-agent[bot] fbfb2fc514 chore: generate 2026-03-16 16:56:12 +00:00
Kit Langton c4ae100b70 refactor(instance): move scoped services to LayerMap (#17544) 2026-03-16 12:55:14 -04:00
DS 0d9fe65a58 fix(opencode): apply message transforms during compaction (#17823) 2026-03-16 11:32:53 -05:00
Johannes Loher 3202268123 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 0a292ad568 fix(app): agent switch should not reset thinking level (#17470) 2026-03-16 19:43:29 +05:30
Kit Langton 70d3e316b3 fix(cli): scope active org labels to the active account (#16957) 2026-03-16 10:01:42 -04:00
Shoubhit Dash d8dc149251 Wrap question option descriptions instead of truncating (#17782) 2026-03-16 11:29:18 +00:00
Luke Parker 3a91561593 fix(ci): workaround by using hoisted Bun linker on Windows (#17751) 2026-03-16 08:39:06 +00:00
Luke Parker 157f5755ac fix(opencode): teach Kit's test what an ID is (#17745) 2026-03-16 18:08:27 +10:00
Jason Quense b04934aa25 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 575159f225 release: v1.2.27 2026-03-16 02:33:48 +00:00
Aiden Cline 02f3ba0600 fix: vcs watcher if statement (#17673) 2026-03-15 19:20:39 -05:00
Erik Engervall 393aa80cae docs: Add opencode-firecrawl to ecosystem documentation (#17672) 2026-03-15 18:56:24 -05:00
Frank 57f814c579 zen: update claude prices 2026-03-15 10:54:40 -04:00
David Hill 382c63bb3f tweak(ui): remove open label (#17512) 2026-03-15 08:25:40 -05:00
Orlando Ascanio 5f32c5f8a0 docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) 2026-03-15 08:22:32 -05:00
Kit Langton b216fd0062 chore(permission): delete legacy permission module (#17534) 2026-03-14 20:56:52 -05:00
Kit Langton 7c49041c37 fix(question): clean up pending entry on abort (#17533) 2026-03-15 00:49:49 +00:00
Sebastian ba8ff3a7b8 remove sighup exit (#17254) 2026-03-15 00:52:28 +01:00
opencode-agent[bot] 8e49012eec chore: generate 2026-03-14 18:29:06 +00:00
Kit Langton 90618eda13 refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511) 2026-03-14 18:28:00 +00:00
Shoubhit Dash 72fed20caa fix(app): handle multiline web paste in prompt composer (#17509) 2026-03-14 22:51:45 +05:30
Kit Langton 641b426f7b refactor(schema): inline branded ID schemas (#17504) 2026-03-14 16:14:46 +00:00
opencode-agent[bot] cab828c262 chore: generate 2026-03-14 15:59:01 +00:00
Kit Langton 4df2577034 refactor(question): effectify QuestionService (#17432) 2026-03-14 11:58:00 -04:00
Aiden Cline a2f522c8c5 tweak: ensure that compaction message is tracked as agent initiated (#17431) 2026-03-14 10:46:24 -05:00
James Long 726caa9bd2 fix(core): increase default chunk timeout from 2 min to 5 min (#17490) 2026-03-14 14:27:06 +00:00
Marcus Schiesser 53e8eccbf2 fix(ui): prevent long filenames from overlapping actions (#17151) 2026-03-13 16:17:15 -05:00
Michael Dwan 3ee10d49a5 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 3e4f676789 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 bc499c9aeb release: v1.2.26 2026-03-13 16:32:53 +00:00
Adam a65fb9a7a9 fix(app): model selection persist by session (#17348) 2026-03-13 11:05:08 -05:00
David Hill 4a931d8a4f fix(app): polish prompt composer controls (#17388) 2026-03-13 10:48:10 -05:00
Adam 9146b837b1 fix(app): sidebar sync 2026-03-13 10:47:45 -05:00
David Hill ee7a5685bd tweak(ui): use new-session icon in sidebar buttons 2026-03-13 10:18:08 -05:00
David Hill e67b88cca2 fix(app): avoid clipping new session during sidebar anim 2026-03-13 10:18:03 -05:00
David Hill 170e5777c2 fix(app): animate titlebar controls on sidebar open 2026-03-13 10:17:56 -05:00
David Hill 524f5de7c2 fix(app): restore sidebar dash and sync session spinner colors (#17384) 2026-03-13 10:08:23 -05:00
Kit Langton 39cee82010 feat(schema): scaffold effect-to-zod bridge (#17273) 2026-03-13 10:59:57 -04:00
Adam 1fefd34c54 fix(app): fork should copy prompt into new session (#17375) 2026-03-13 09:59:11 -05:00
Jack fd986f015a fix(ui): force wasm highlighter for markdown code blocks (#17373) 2026-03-13 09:57:14 -05:00
David Hill 03069399bc fix(app): sidebar spacing + session list spinner transition (#17355) 2026-03-13 09:19:02 -05:00
Adam 4b5fd4683a fix(app): terminal cloning without retry (#17354) 2026-03-13 08:56:48 -05:00
Adam 7e0a596cf0 fix(app): scroll falls behind prompt input 2026-03-13 08:39:42 -05:00
Adam e43f0ff375 fix(app): tooltip quirks 2026-03-13 08:38:32 -05:00
Adam 052e3fecdf fix(app): todo spacing 2026-03-13 07:43:50 -05:00
Adam a559ef332b fix(app): better optimistic prompt submit (#17337) 2026-03-13 07:38:03 -05:00
Shoubhit Dash e7bccd6d12 fix(opencode): serialize config bun installs (#17342) 2026-03-13 17:58:00 +05:30
Adam 9b5acb0919 fix(app): oc-2 went too dark 2026-03-13 07:25:42 -05:00
Adam ac0640f4ca fix(app): support text attachments (#17335) 2026-03-13 06:58:24 -05:00
Adam 01dea97657 chore(app): i18n sync (#17283) 2026-03-13 06:48:38 -05:00
Adam cff676ae63 chore: cleanup (#17284) 2026-03-13 06:27:58 -05:00
Shoubhit Dash 0e3cb695d5 perf(app): use cursor session history loading (#17329) 2026-03-13 16:43:41 +05:30
David Hill 440a8d9290 Remove prompt mode toggle (#17216) 2026-03-13 05:38:34 -05:00
opencode-agent[bot] 864e46215e chore: generate 2026-03-13 10:19:52 +00:00
Shoubhit Dash 984e0b66ad perf(server): paginate session history (#17134) 2026-03-13 15:48:43 +05:30
Adam d62c4c41fe fix(app): simplify themes (#17274) 2026-03-13 10:12:58 +00:00
Frank 26c12a7898 go: upi pay 2026-03-13 03:24:33 -04:00
Aiden Cline b9fc95af48 chore: cleanup migrate from global code (#17292) 2026-03-12 23:54:11 -05:00
Michael Dwan f02ea4d115 fix(opencode): sessions lost after git init in existing project (#16814)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-12 23:18:59 -05:00
Luke Parker bf6b51e4aa fix(e2e): fail fast on config dependency installs (#17280) 2026-03-13 14:08:51 +10:00
Adam 11a0607d93 fix(app): list item background colors 2026-03-12 22:23:51 -05:00
Adam 11ef4651a4 feat(app): missing themes (#17275) 2026-03-12 22:21:02 -05:00
Tom Ryder 16edfe662d fix: filter empty content blocks for Bedrock provider (#14586) 2026-03-12 22:13:09 -05:00
opencode-agent[bot] 2327da1c18 chore: generate 2026-03-13 02:33:45 +00:00
Kit Langton d3b47c2390 fix(cli): hide console command from help output (#17277) 2026-03-12 22:22:16 -04:00
Luke Parker 8ea7713f54 fix(app): stabilize todo dock e2e with composer probe (#17267) 2026-03-13 12:21:50 +10:00
opencode-agent[bot] 2ebfc38700 chore: generate 2026-03-13 02:10:27 +00:00
Kit Langton 35f8e9ca55 refactor(provider): effectify ProviderAuthService (#17227) 2026-03-13 02:08:37 +00:00
Brendan Allan 9175eeca8f desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Kit Langton 9077726a47 feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton 4a4b0edc48 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline d318add4a0 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Luke Parker 0ae4558ce9 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton a14e43828f reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam 68fc806548 feat(app): interruption state 2026-03-12 19:07:23 -05:00
Luke Parker f6c30b85cf fix(ci): keep test runs on dev (#17260) 2026-03-13 09:54:34 +10:00
opencode 2a959e034e release: v1.2.25 2026-03-12 23:34:11 +00:00
Frank 9f2549fe7e zen: handle cache key 2026-03-12 19:10:58 -04:00
Adam 8607793180 fix(app): sidebar re-rendering too often 2026-03-12 16:27:15 -05:00
Adam c34e94b4f5 fix(app): task error state 2026-03-12 16:25:49 -05:00
Adam 9e4fa3c2c7 fix(app): message loading 2026-03-12 16:25:48 -05:00
Aiden Cline 4178addbca fix: if server password exists, use basic auth for plugin client by default (#17213) 2026-03-12 15:41:46 -05:00
Adam 21ee9d3500 feat(app): follow-up behavior (#17233) 2026-03-12 20:17:36 +00:00
Adam 618e35dec7 fix(app): optimistic revert/restore 2026-03-12 15:04:27 -05:00
Adam 0f31266217 feat(app): AMOLED theme 2026-03-12 14:11:22 -05:00
Adam 7964158f5a fix(app): not loading message nav 2026-03-12 13:33:17 -05:00
David Hill 9dbe0d1016 fix(app): titlebar cleanup (#17206) 2026-03-12 18:26:50 +00:00
Frank 7450452ecd zen: fix nemotron issue 2026-03-12 14:17:36 -04:00
Adam 8a09348b2d chore: cleanup (#17197) 2026-03-12 11:32:05 -05:00
Adam 7f4314d0a9 feat: session load perf (#17186) 2026-03-12 11:31:52 -05:00
Adam cbfdbad4f0 feat(app): better themes (#16889) 2026-03-12 10:35:23 -05:00
opencode-agent[bot] d4bd1d430d chore: update nix node_modules hashes 2026-03-12 15:07:54 +00:00
Aiden Cline c7cc49da66 fix: non openai azure models that use completions endpoints (#17128) 2026-03-12 10:05:00 -05:00
Kit Langton 423a0b25a9 refactor(import): use .parse() at boundaries instead of manual .make() (#17106) 2026-03-12 10:52:20 -04:00
opencode-agent[bot] a8a1b40406 chore: generate 2026-03-12 14:49:22 +00:00
Kit Langton 82307f8a45 refactor(provider): flow branded ProviderID/ModelID through internal signatures (#17182) 2026-03-12 14:48:17 +00:00
Wang Siyuan aee9982537 docs: clarify subagent session navigation keybinds (#16455) 2026-03-12 09:45:46 -05:00
Adam ace51ae703 chore: cleanup (#17184) 2026-03-12 08:52:51 -05:00
max tomashevsky a0e74f99c7 fix(web): fix broken mobile sidebar (in workflows mode) sizing issue by adding flex-1 (#17055) 2026-03-12 08:46:41 -05:00
Adam e4bb560e99 fix(app): terminal animation 2026-03-12 08:43:25 -05:00
Adam f47f1871c2 chore: cleanup 2026-03-12 08:41:22 -05:00
opencode-agent[bot] 7ae87da35a chore: generate 2026-03-12 13:28:46 +00:00
Kit Langton 36aee4908f feat(id): brand ProviderID and ModelID (#17110) 2026-03-12 09:27:52 -04:00
Adam cb35c58c45 fix(app): sidebar quirks 2026-03-12 07:51:31 -05:00
Adam 7c1e2a6df4 chore: cleanup (#17115) 2026-03-12 12:26:43 +00:00
opencode-agent[bot] 4a100d6b59 chore: update nix node_modules hashes 2026-03-12 08:26:49 +00:00
Brendan Allan 54adc3111e refactor(desktop): rework default server initialization and connection handling (#16965) 2026-03-12 08:10:52 +00:00
opencode-agent[bot] f58ccb50ac chore: generate 2026-03-12 07:36:35 +00:00
Luke Parker 035e39752e Fix terminal e2e flakiness with a real terminal driver (#17144) 2026-03-12 17:35:26 +10:00
OpeOginni 530d45695d fix(desktop): set default WebSocket username and prevent repeated calling of terminal spawn properly closing the terminal (#17061) 2026-03-12 14:48:44 +08:00
Luke Parker 7bd3d7d03f fix(electron): theme Windows titlebar overlay (#16843)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-03-12 16:38:56 +10:00
Luke Parker 46c9443913 fix(desktop-electron): fix resource loading under file:// protocol (#17125) 2026-03-12 12:19:44 +08:00
opencode-agent[bot] b4f2ba4ca9 chore: generate 2026-03-12 03:56:04 +00:00
Luke Parker 411fdfc405 fix(app): guard comment accessor in message timeline (#17126) 2026-03-12 13:55:16 +10:00
Dax Raad cdb4e87872 Merge remote-tracking branch 'origin/dev' into dev 2026-03-11 23:24:38 -04:00
Dax Raad bd1dbc02db core: remove external sourcemap generation to reduce build artifacts 2026-03-11 23:24:26 -04:00
Luke Parker ec4b4dc0c3 fix(ci): use dynamic bun cache path for cross-platform support (#17120) 2026-03-12 03:19:28 +00:00
opencode-agent[bot] 49de3431b3 chore: generate 2026-03-12 02:04:26 +00:00
Luke Parker 99ca8955b6 fix(e2e): re-focus prompt after terminal opens in slash-terminal test (#17113) 2026-03-12 12:03:38 +10:00
opencode-agent[bot] 78b0b92f2f chore: generate 2026-03-12 01:50:44 +00:00
Kit Langton a0e83f2c5f feat(id): brand PermissionID, PtyID, QuestionID, and ToolID (#17042) 2026-03-12 01:49:57 +00:00
opencode-agent[bot] 74984fdfc6 chore: generate 2026-03-12 00:26:05 +00:00
Luke Parker 8c6ddb0afe feat(windows): add arm64 release targets for cli and desktop (#16696) 2026-03-12 00:25:09 +00:00
opencode-agent[bot] 2eb28003d6 chore: generate 2026-03-11 23:41:43 +00:00
Kit Langton 2a225d7c72 feat(id): brand PartID through Drizzle and Zod schemas (#16966) 2026-03-11 23:40:50 +00:00
opencode-agent[bot] dfb60d16f3 chore: generate 2026-03-11 23:31:07 +00:00
Kit Langton b9eaabe80b feat(id): brand WorkspaceID through Drizzle and Zod schemas (#16964) 2026-03-11 23:30:17 +00:00
John Mylchreest 86b5c7dd61 fix: resolve symlinks in Instance cache to prevent duplicate contexts (#16651)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-11 23:26:54 +00:00
opencode-agent[bot] 0f854235ee chore: generate 2026-03-11 23:17:42 +00:00
Kit Langton 4401c08142 feat(id): brand SessionID through Drizzle and Zod schemas (#16953) 2026-03-11 23:16:56 +00:00
Frank 07da695d86 wip: zen 2026-03-11 19:00:05 -04:00
Frank ecca6985ac wip: zen 2026-03-11 18:50:49 -04:00
Frank 27d7b71393 docs: go pricing 2026-03-11 18:09:41 -04:00
Frank 3a5dc9c7f8 docs: zen update models 2026-03-11 18:09:41 -04:00
Frank 5feba1f51d wip: zen 2026-03-11 18:09:41 -04:00
Frank d64a6fdb28 go: dashboard design 2026-03-11 18:09:41 -04:00
Frank 61330dfb8a go: first month discount 2026-03-11 18:09:41 -04:00
Frank 734aff9848 zen: update discount copy on lander 2026-03-11 18:09:41 -04:00
Frank 9e554fe940 zen: add alipay for adding balance 2026-03-11 18:09:41 -04:00
Frank 928d025f14 zen: add alipay for go sub 2026-03-11 18:09:41 -04:00
Frank cfe86a403f zen: add Go page 2026-03-11 18:09:41 -04:00
Frank 93c73eae7a zen: add usage section 2026-03-11 18:09:40 -04:00
Frank a925d7293b zen: update usage graph on landing page 2026-03-11 18:09:40 -04:00
Frank 1044c92aec zen: update header 2026-03-11 18:09:40 -04:00
Dax Raad 27d07c7e23 core: restore plugin serverUrl getter so plugins can connect to local server 2026-03-11 17:41:51 -04:00
Adam cb257cf5ce feat(app): restore to message and fork session (#17092) 2026-03-11 21:34:48 +00:00
Adam f814e7a828 chore: skip test 2026-03-11 16:21:04 -05:00
Noam Bressler c717c8c67e fix: re-enable snapshot in acp (#14918) 2026-03-11 16:18:40 -05:00
Adam 827c26921a chore: fix test 2026-03-11 16:11:02 -05:00
Aiden Cline 88a6d5445c tweak: adjust skill presentation to be a little less token heavy (#17098) 2026-03-11 16:03:15 -05:00
Adam 37b095bba7 chore: fix test 2026-03-11 16:02:11 -05:00
Kit Langton 9ef3ec79cd feat(id): brand ProjectID through Drizzle and Zod schemas (#16948) 2026-03-11 16:44:26 -04:00
Adam 04c61aef6f fix(app): todos not clearing 2026-03-11 14:42:34 -05:00
Chris Yang 711b84442b fix(app): skip editor reconcile during IME composition (#17041) 2026-03-11 13:40:06 -05:00
Kit Langton 65569dbdb1 refactor(account): tighten effect-based account flows (#17072) 2026-03-11 18:18:58 +00:00
Johannes Loher a34083a54e fix(core): read stdout and stderr in PackageRegistry.info before waiting for the process to exit (#16998) 2026-03-11 13:10:45 -05:00
Adam 2034d916b9 chore(app): simplify review pane (#17066) 2026-03-11 12:24:51 -05:00
xinxin d7707e5bae docs(providers): clarify npm choice for chat vs responses APIs (#16974)
Co-authored-by: wangxinxin <xinxin.wang@pharmbrain.com>
2026-03-11 10:35:16 -05:00
Aiden Cline 250925fca8 tweak: adjust way skills are presented to agent to increase likelyhood of skill invocations. (#17053) 2026-03-11 10:24:55 -05:00
Shoubhit Dash e1ccd985c6 perf(app): trim session render work (#16987) 2026-03-11 18:19:17 +05:30
Filip 053ae6ccc9 fix(app): make error tool card respect settings (#17005) 2026-03-11 14:52:33 +05:30
Brendan Allan b534031e0d ci: setup node in tauri build 2026-03-11 16:09:17 +08:00
opencode-agent[bot] 4970adae6f chore: generate 2026-03-11 03:34:02 +00:00
Luke Parker 52e829aa32 fix(electron): hide Windows background consoles (#16842)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-11 13:33:06 +10:00
SOUMITRA-SAHA 3c48a076ce fix: add GOOGLE_VERTEX_LOCATION env var support for Vertex AI (#16922)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-10 22:32:39 -05:00
Jack 8fb936ecc7 feat(web): use Feishu for Chinese community links (#16908)
Co-authored-by: Frank <frank@anoma.ly>
2026-03-11 11:07:13 +08:00
Kit Langton 4dc6e6fc99 fix(account): resilient orgs fetch (#16944) 2026-03-11 00:39:07 +00:00
Dax Raad 1c7ae1f24a sync 2026-03-10 20:13:56 -04:00
Adam d7b497a0ce chore(app): fix tests 2026-03-10 19:03:44 -05:00
Aiden Cline 9e441a3ec3 chore: kill old copilot 403 message that was used for old plugin migration (#16904) 2026-03-10 16:20:41 -05:00
Dax Raad fad2b23698 ci: cancel duplicate workflow runs and add read permissions
- Add concurrency settings to cancel outdated runs when new commits are pushed
- Add contents: read permission for security hardening
- Remove redundant required job that checked test results
2026-03-10 17:17:11 -04:00
Frank a06e67fdfc zen: raise limit 2026-03-10 15:22:02 -04:00
David Hill 7524ce20f4 feat(ui): restyle Card and improve tool error cards (#16888)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-10 13:52:32 -05:00
Frank de8b9215fb zen: raise limit 2026-03-10 14:40:18 -04:00
Dax Raad a3acc03f22 ci: exclude draft PRs from beta labeling process to prevent unfinished work from being included in releases 2026-03-10 14:08:55 -04:00
Dax Raad 3ee64041c5 ci: install setuptools to prevent Python distutils errors during dependency installation 2026-03-10 14:08:38 -04:00
Adam 3b090b22e8 chore: cleanup 2026-03-10 13:00:14 -05:00
Adam 7e290fd1b8 fix(app): terminal jank 2026-03-10 13:00:14 -05:00
Adam 1fdfc6ad32 fix(app): terminal focus issues and jank 2026-03-10 13:00:14 -05:00
opencode-agent[bot] c7f573b041 chore: update nix node_modules hashes 2026-03-10 17:14:55 +00:00
Dax 9ab72a07ed Fix ESM imports for @opencode-ai/plugin (#16916) 2026-03-10 13:11:36 -04:00
opencode-agent[bot] b407d86f88 chore: generate 2026-03-10 16:56:30 +00:00
Dax f56e6684a6 core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:53:47 -04:00
Adam 540acca843 fix(core): don't permit access to system directories (#16891) 2026-03-10 11:32:05 -05:00
Aiden Cline 2a6964e3e1 chore: rekram1-node is no longer on vacation (#16905) 2026-03-10 10:27:04 -05:00
opencode-agent[bot] c992241079 chore: generate 2026-03-10 15:14:46 +00:00
James Long df704db089 feat(core): allow passing workspaceID into session create endpoint (#16798) 2026-03-10 11:12:51 -04:00
James Long 01233eab2e fix(core): a chunk timeout when processing llm stream (#16366) 2026-03-10 11:12:14 -04:00
James Long 03c8b44189 fix(core): make worktrees read the project id from local workspace (#16795) 2026-03-10 11:11:28 -04:00
opencode-agent[bot] 01d2dd20f9 chore: generate 2026-03-10 15:07:35 +00:00
Jérôme Benoit 565aefc073 fix(opencode): add thinking variants support for SAP AI provider (#14958)
Co-authored-by: Test <test@test.com>
Co-authored-by: Stephen Collings <stevoland@gmail.com>
2026-03-10 10:05:45 -05:00
Adam 87d5c9087f chore(app): debug window 2026-03-10 07:05:54 -05:00
opencode-agent[bot] 22b8b6e23f chore: generate 2026-03-10 11:01:22 +00:00
bhaktatejas922 2c7fa0067c docs: update opencode-morph-plugin in all language ecosystem pages (#16869) 2026-03-10 06:00:13 -05:00
opencode-agent[bot] e2edb87a8d chore: update nix node_modules hashes 2026-03-10 10:16:15 +00:00
Sebastian 6b1f91ce2d upgrade opentui to v0.1.87 (#16772) 2026-03-10 11:03:05 +01:00
opencode-agent[bot] 937d1f4565 chore: generate 2026-03-10 02:02:18 +00:00
adam jones 3910ef3ce3 fix(mcp): fix OAuth auto-connect failing on first connection (#15547)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 21:01:19 -05:00
Dax Raad 7e10f80b49 refactor(server): extract createApp function for server initialization
- Replace Server.App() with Server.Default() for internal server access
- Extract server app creation into Server.createApp(opts) for testability
- Move CORS whitelist from module-level variable to function parameter
- Update all tests to use Server.Default() instead of Server.App()
2026-03-09 17:13:52 -04:00
Adam 0a06fac8fb chore(app): dev stats 2026-03-09 15:57:24 -05:00
Adam 230e82d5a4 fix(app): terminal state corruption 2026-03-09 15:28:35 -05:00
Adam 4d591d245d chore: cleanup 2026-03-09 15:28:35 -05:00
Adam 12836be1aa chore: cleanup 2026-03-09 15:28:35 -05:00
opencode-agent[bot] 60d24b76a9 chore: generate 2026-03-09 20:15:31 +00:00
James Long fea9bc2077 fix(core): put workspace routing behind OPENCODE_EXPERIMENTAL_WORKSPACES flag (#16775) 2026-03-09 16:14:19 -04:00
opencode-agent[bot] 4f7a1faf56 chore: update nix node_modules hashes 2026-03-09 19:51:58 +00:00
opencode-agent[bot] 353dc441fe chore: update nix node_modules hashes 2026-03-09 19:49:01 +00:00
bhaktatejas922 3745f25a75 docs: update opencode-morph-fast-apply to opencode-morph-plugin in ecosystem (#16634) 2026-03-09 14:39:06 -05:00
opencode-agent[bot] a4c3be1b1a chore: generate 2026-03-09 19:31:33 +00:00
Dax Raad 887debd8fa Merge remote-tracking branch 'origin/dev' into dev 2026-03-09 15:30:15 -04:00
Dax Raad a10faea93a refactor: replace bun semver with npm semver package 2026-03-09 15:29:55 -04:00
Kyle Altendorf 4da2d0150d fix(tui): wait for model store before auto-submitting --prompt (#7476) 2026-03-09 14:22:38 -05:00
Dax 89f4344882 refactor(opencode): replace Bun shell in core flows (#16286) 2026-03-09 15:19:50 -04:00
Dax Raad c854a6917d refactor: change pathToFileURL imports from bun to url module 2026-03-09 14:52:25 -04:00
James Long 665cdf6986 fix(tui): handle error when creating a session (#16767) 2026-03-09 12:13:32 -04:00
Stephen Collings e7796d4973 fix: Multiple jdtls LSPs eating memory in java monorepos (#12123) 2026-03-09 16:09:43 +00:00
opencode bc8f94a5ff release: v1.2.24 2026-03-09 16:09:34 +00:00
Adam 53ecb769dc fix(app): scroll jitter/loop 2026-03-09 10:44:02 -05:00
Dax Raad 914a2adca0 core: prevent accidental edits to migration files by restricting agent access 2026-03-09 11:25:58 -04:00
David Hill 3d941a6672 fix(app): session title turn spinner (#16764) 2026-03-09 09:46:15 -05:00
Filip 6fabb4f996 fix(app): dismiss toast notifications when questions or permissions a… (#16758) 2026-03-09 09:36:57 -05:00
Karan Handa b6484e6bdb fix(storybook): add ci build workflow (#16760) 2026-03-09 09:33:19 -05:00
James Long 1aff94d7ad feat(tui): add initial support for workspaces into the tui (#16230) 2026-03-09 10:28:04 -04:00
Armin Pašalić 4204c932d0 feat(gitlab): send context-1m-2025-08-07 beta header to enable 1M context window (#16153)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 09:22:00 -05:00
Jack 5712554c4a add copilot gpt-5.4 xhigh support (#16294) 2026-03-09 22:07:12 +08:00
opencode d13753e7c4 release: v1.2.23 2026-03-09 13:50:43 +00:00
Adam 3b59be4dd8 fix(app): remove oc-1 theme 2026-03-09 08:25:41 -05:00
David Hill 56af5f592b tui: fix sidebar workspace container sizing by adding box-border class to prevent content overflow issues 2026-03-09 13:05:43 +00:00
Adam 645ca29992 fix(app): don't animate review panel in/out 2026-03-09 07:49:11 -05:00
opencode-agent[bot] a9632aa16e chore: update nix node_modules hashes 2026-03-09 12:46:47 +00:00
Adam a267c02d2a revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745) 2026-03-09 07:36:39 -05:00
Luke Parker 3ddf338d8c fix(app): sanitize workspace store filenames on Windows (#16703) 2026-03-09 20:26:53 +10:00
Yihui Khuu fd2d06459f fix(app): fix issue with scroll jumping when pressing escape in comment text area (#15374) 2026-03-09 15:29:24 +05:30
Eric Clemmons dec0a3b85f fix(opencode): pass missing auth headers in run --attach (#16097)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-09 07:32:13 +00:00
opencode-agent[bot] 18977bf9bc docs(i18n): sync locale docs from english changes 2026-03-09 02:08:46 +00:00
Ariane Emory 9a9f0ca08c docs: add session_child_first keybinding to documentation (#16631) 2026-03-08 21:03:52 -05:00
Adam ef130a7417 feat(app): generate color palettes (#16232) 2026-03-08 19:28:58 -05:00
Dax Raad 8a26bc33cf disable fallback to free nano for small model 2026-03-08 19:27:15 -04:00
opencode 7d837b6f1c release: v1.2.22 2026-03-08 22:34:59 +00:00
Sebastian 496f96721f guard tui exit (#16640) 2026-03-08 23:14:41 +01:00
Luke Parker 7c79a5a849 fix(tui): canonicalize cwd after chdir (#16641) 2026-03-09 07:57:48 +10:00
David Hill d512909fc5 tui: fix sidebar background color when collapsed
When the sidebar was collapsed (not on mobile), the background color was showing as the stronger variant instead of matching the base background. This fixes the hover state detection so users see a consistent lighter background when the sidebar is in collapsed mode.
2026-03-08 13:34:56 +00:00
David Hill 7d903b7f34 app: suppress hover when opening project menu or right-clicking to prevent flickering 2026-03-08 13:31:18 +00:00
Shoubhit Dash 008ac7eae9 fix: trim retained desktop terminal buffers (#16583) 2026-03-08 07:50:04 -05:00
opencode-agent[bot] e8b2a58f88 chore: update nix node_modules hashes 2026-03-08 12:25:35 +00:00
Adam 21373267cf fix(app): less auto-expand/collapse 2026-03-08 07:11:15 -05:00
Adam 3b2ae9d2a1 chore: refactoring ui hooks 2026-03-08 07:11:15 -05:00
Adam 48a19c2391 fix(app): messages not loading reliably 2026-03-08 07:11:15 -05:00
Shoubhit Dash fe109b3f17 fix: prune and evict stale app session caches (#16584) 2026-03-08 07:10:00 -05:00
Shoubhit Dash 096f2c698f test: make process cwd check cross-platform (#16594) 2026-03-08 06:56:45 -05:00
Roy Bruschini 6f3c73898c docs(zen.mdx): correct Italian grammar and punctuation errors (#16590) 2026-03-08 16:40:06 +05:30
tobwen 9130c188d6 fix(opencode): avoid TTY corruption from double cleanup (#16565)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:55:33 +05:30
Nate Williams b022adb41c fix(tui): fix broken /mcp toggling (#16431)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:31:09 +05:30
Dax 442ecf5ee0 test: remove unused Ripgrep.search coverage (#16554) 2026-03-07 21:40:57 -05:00
David Hill bd42be52a7 Revert "tui: dock auto-accept after thinking and move Add file to bottom-left"
This reverts commit 4c457ba392.
2026-03-08 01:31:09 +00:00
David Hill b53991ee03 Revert "tui: keep model + thinking selectors beside Add file"
This reverts commit e22cd8bd4e.
2026-03-08 01:31:06 +00:00
David Hill 2db0ac25cb Revert "tui: add more editor bottom padding for prompt controls"
This reverts commit bf1e48ff8e.
2026-03-08 01:31:03 +00:00
David Hill 7585aabf0f Revert "tui: expose auto-accept as a permissions select"
This reverts commit ef0f922461.
2026-03-08 01:31:00 +00:00
David Hill 53798b2c3b Revert "tui: use text-base color for prompt selects"
This reverts commit 31745d8009.
2026-03-08 01:30:55 +00:00
David Hill d999d6d48b Revert "tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple"
This reverts commit d4eec7e5da.
2026-03-08 01:27:45 +00:00
David Hill d4eec7e5da tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple 2026-03-08 01:21:45 +00:00
David Hill aa419b050d tui: restore new-session logo on dev so users recognize OpenCode immediately 2026-03-08 01:18:42 +00:00
David Hill e02914f18a tui: revert new-session logo on dev so this UI change only ships with auto-accept-permissions 2026-03-08 01:10:52 +00:00
David Hill 86051adc9d tui: add opencode logo to new session screen so users can immediately identify the app when starting a fresh session 2026-03-08 00:59:03 +00:00
David Hill 31745d8009 tui: use text-base color for prompt selects
Select triggers in the composer now use the normal text color so model/thinking/permissions controls read consistently with the rest of the input UI.
2026-03-08 00:53:57 +00:00
David Hill ef0f922461 tui: expose auto-accept as a permissions select
Lets people explicitly choose between normal permission prompts and auto-accept while composing, without relying on an ambiguous icon state.
2026-03-08 00:53:57 +00:00
David Hill bf1e48ff8e tui: add more editor bottom padding for prompt controls
Gives typed text more breathing room above the Add file/model/thinking row so the controls don’t visually crowd what you’re writing.
2026-03-08 00:53:57 +00:00
David Hill e22cd8bd4e tui: keep model + thinking selectors beside Add file
People change models and thinking settings while composing, so keeping those controls next to the Add file button avoids hunting in the footer and reduces context switching mid-message.
2026-03-08 00:53:57 +00:00
David Hill 4c457ba392 tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:53:57 +00:00
Dax Raad 9685f7dbaa test: normalize ripgrep path assertion on windows 2026-03-07 19:47:57 -05:00
opencode-agent[bot] 832c086822 chore: generate 2026-03-08 00:31:35 +00:00
Dax Raad 8330999cea test: lock in process, ripgrep, and installation helpers 2026-03-07 19:30:32 -05:00
David Hill 64858dc353 tui: revert prompt control docking
Restore the previous prompt control layout after the dock/position changes made the composer feel less familiar.

This brings auto-accept back to its prior spot and returns Add file to the previous placement.
2026-03-08 00:17:28 +00:00
David Hill ff5dbb8474 tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:08:37 +00:00
Dax Raad 62fe4ae644 core: update database path test to verify correct channel-based filename
The test now validates that the database file is named according to the current installation channel (latest/beta get 'opencode.db', others get sanitized names). This ensures users' data is stored in the correct location based on their update channel.
2026-03-07 18:53:29 -05:00
Dax Raad f6c48cf6b2 core: allow beta channel to share database with stable channel 2026-03-07 18:53:29 -05:00
David Hill 64d04a91b7 tui: balance titlebar columns so center content doesn't get squeezed by long side content 2026-03-07 23:50:23 +00:00
David Hill 05362100f7 tui: align numeric displays consistently across tool outputs and diff counters using tabular numerals 2026-03-07 23:49:10 +00:00
David Hill c9533ac67a tui: center empty states vertically in session view and improve review panel messaging for projects without version control 2026-03-07 23:45:16 +00:00
Dax Raad 35e1142383 core: add OPENCODE_SKIP_MIGRATIONS flag to bypass database migrations
Allows users to skip automatic database migrations by setting the
OPENCODE_SKIP_MIGRATIONS environment variable. Useful for testing
scenarios or when manually managing database state.
2026-03-07 16:17:00 -05:00
Frank 2675680768 zen: fix graph legend 2026-03-07 14:28:36 -05:00
Adam 56ba5c7fce fix(app): review panel transition 2026-03-07 13:27:44 -06:00
opencode 29f129acbb release: v1.2.21 2026-03-07 18:00:39 +00:00
Karan Handa 9f1ab5e855 fix(storybook): restore build by mocking useLocation (#16472) 2026-03-07 09:55:43 -06:00
Eric Guo a1bb6a4047 fix(app): text-shimmer undefined length (#16475) 2026-03-07 09:53:32 -06:00
SANGWOO PARK 5b6fba32f0 fix(app): guard session-header current() against undefined when options is empty (#16478) 2026-03-07 09:51:21 -06:00
Filip 879a092d9b fix(app): preserve file tree tab on reopen + fix e2e test regressions (#16482) 2026-03-07 09:47:45 -06:00
David Hill 8cfd7c4066 tui: remove close button from project hover popover (#16403)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-07 07:00:58 -06:00
Adam 792f3363ba fix(app): new session uses agent model/variant 2026-03-07 07:00:38 -06:00
Adam 38add89866 fix(app): model sticks to session 2026-03-07 06:57:00 -06:00
Adam 482538010c fix(app): all panels transition 2026-03-07 06:48:37 -06:00
Adam 28afc494ac fix(app): can't scroll files 2026-03-07 06:47:11 -06:00
Adam 1ce8b0f17f fix(app): task agent title 2026-03-07 06:03:30 -06:00
Adam 0b54f0c92a chore: update web stats 2026-03-07 05:47:47 -06:00
Adam 97a068cb7f fix(app): fix max-width on timeline 2026-03-07 05:45:30 -06:00
David Hill c67ac136d4 tui: align session empty states (#16412) 2026-03-07 05:39:43 -06:00
opencode-agent[bot] 18a9142252 chore: generate 2026-03-07 11:26:12 +00:00
Kit Langton 07121ef1a6 STUPID SEXY TIMELINE (#16420) 2026-03-07 05:25:22 -06:00
Luke Parker f83cd8f1bc test(app): share workspace slug wait helper across e2e specs (#16446) 2026-03-07 07:48:30 +00:00
Quan Ran d66fc779dc fix(opencode): preserve original line endings in 'edit' tool (#9443)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-07 07:42:54 +00:00
Nate Williams e83bc8c43c fix(tui): fix broken /export toggling (#16443) 2026-03-07 12:52:17 +05:30
Luke Parker 32deb1a227 test(app): abort sessions and wait for idle before e2e cleanup (#16439) 2026-03-07 16:33:12 +10:00
Karan Handa e8b0f38508 fix(config): point GitHub PR search tool at current repository (#16441) 2026-03-07 05:53:28 +00:00
Luke Parker c47c5ec9a0 fix(windows): git path resolution for modified files across Git Bash, MSYS2, and Cygwin (#16422) 2026-03-07 15:42:14 +10:00
opencode-agent[bot] 5d128075c1 chore: generate 2026-03-07 04:54:05 +00:00
kikuchan b95c982987 fix(pty): pty session handle leak (#15599) 2026-03-07 14:53:11 +10:00
Luke Parker 8589da78bc feat(desktop): show skill issue when snapshotting is off (#16432) 2026-03-07 04:52:04 +00:00
Luke Parker 3ca580177b fix(opencode): sanitize preview database filenames (#16430) 2026-03-07 04:34:29 +00:00
opencode-agent[bot] ed8068d156 chore: update nix node_modules hashes 2026-03-07 02:35:17 +00:00
David Hill 7a097c6325 app: fix portal positioning for sidebar menus and tooltips by removing conditional mount logic 2026-03-07 00:30:51 +00:00
Jay V c9d2ca8126 docs: update legal policies for 2026 terms refresh 2026-03-06 18:31:02 -05:00
Filip a3bc0bcf09 tests(app): e2e tests part 67 (#16406) 2026-03-06 17:08:59 -06:00
opencode-agent[bot] cb55da8f03 chore: generate 2026-03-06 22:34:25 +00:00
David Hill fdd5375bca feat(app): sidebar reveal animation, hover peek overlay, and weaker dividers (#16374)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-06 16:33:34 -06:00
James Long cd70c57e99 fix(core): log stack trace when schema validation fails (#16401) 2026-03-06 17:04:22 -05:00
Dax Raad 0c1fee138a fix issue with migration 2026-03-06 16:44:49 -05:00
David Hill 507f773bca fix(app): better review/filetree empty states (#16221)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-06 15:39:31 -06:00
Adam d147b7b915 chore: fix test 2026-03-06 15:27:49 -06:00
opencode-agent[bot] 524b9b9167 chore: generate 2026-03-06 19:40:36 +00:00
Shoubhit Dash eafc014be6 feat: add project git init api (#16383) 2026-03-06 13:39:50 -06:00
Alexandre Reyes Martins 4dc578b822 fix(app): enable Safari autocorrect in normal mode, disable in shell mode (#15563) 2026-03-06 22:36:27 +05:30
Adam ae93b9f16d fix(app): stale keyed show errors 2026-03-06 11:03:37 -06:00
Hoshiumi Arata 8f1b1f26b7 fix(ui): prevent unwanted key events during composition in LineCommentEditor (#16361) 2026-03-06 11:01:13 -06:00
Dax Raad 9f34ffff3d chore(storage): update drizzle and channel db handling 2026-03-06 10:58:19 -05:00
Adam c6c0d8ec04 fix(app): part type gate 2026-03-06 09:56:02 -06:00
Shoubhit Dash e38846ef43 share: speed up share loads (#16165) 2026-03-06 06:49:15 -06:00
Adam ef1cec6a9f Revert "fix(app): stale read error"
This reverts commit a27ea86940.
2026-03-06 05:52:48 -06:00
Adam 278c1d8b3e Revert "fix(app): stale keyed show errors"
This reverts commit b72468d5fc.
2026-03-06 05:52:47 -06:00
opencode-agent[bot] dc13b0275f chore: generate 2026-03-06 11:48:31 +00:00
Kirill Tregubov 57475232a3 fix(app): add english to locale matchers (#16280) 2026-03-06 05:47:39 -06:00
D c6158b508d feat: Add Vietnamese README and update all language navigation links … (#16322) 2026-03-06 05:46:53 -06:00
opencode-agent[bot] ee9e8e9e34 chore: generate 2026-03-06 10:50:00 +00:00
Luis Felipe Cordeiro Sena e41a64d035 fix(app): enable auto-accept keybind regardless of permission config (#16259) 2026-03-06 04:49:12 -06:00
opencode 2401e05023 release: v1.2.20 2026-03-06 07:29:34 +00:00
Dax 0e44e3803a fix: restore Bun stdin reads for prompt input (#16300) 2026-03-06 02:08:10 -05:00
Luke Parker aa99b73b59 fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249) 2026-03-06 15:42:08 +10:00
opencode-agent[bot] f99c8c2784 chore: update nix node_modules hashes 2026-03-06 05:31:01 +00:00
Dax 46beeaa585 refactor(opencode): replace Bun.which with npm which (#15012) 2026-03-06 05:18:29 +00:00
opencode 899d08f869 release: v1.2.19 2026-03-06 04:29:10 +00:00
Sadık 5634202382 feat: add "gpt-5.4" to codex allowed models list (#16274) 2026-03-05 23:03:01 -05:00
Dax Raad 493ad25b2c refactor: replace Bun.stderr and Bun.color with Node.js equivalents 2026-03-05 22:20:16 -05:00
Dax Raad 8f6e3ad6de refactor: replace Bun.connect with net.createConnection 2026-03-05 22:17:08 -05:00
Dax Raad fbce003e15 fix: use sha1 for hash instead of unsupported xxhash3-xxh64 2026-03-05 22:12:10 -05:00
Dax Raad a289e15cb0 refactor: use node:stream/consumers for stdin reading 2026-03-05 22:08:50 -05:00
Dax Raad 1aa25a9ea3 refactor: replace Bun.stdin.text with Node.js stream reading 2026-03-05 22:04:20 -05:00
Dax Raad 25643cffc4 refactor: replace Bun.hash with Hash.fast using xxhash3-xxh64 2026-03-05 22:03:24 -05:00
Dax Raad 67827c5cb1 refactor: replace Bun.write with Filesystem.write in config files 2026-03-05 21:59:20 -05:00
Dax Raad f8c6032d3c refactor: replace Bun.write/file with Filesystem utilities in snapshot 2026-03-05 21:56:41 -05:00
Dax 9c6d6ca359 refactor: replace Bun.sleep with node timers (#15013)
publish-github-action / publish (push) Waiting to run
2026-03-05 21:54:06 -05:00
Filip a9853eae40 fix(app): stale show (#16236) 2026-03-05 18:23:48 -06:00
David Hill 548d9ed634 tui: remove keyboard shortcut tooltips from new session and new workspace buttons in the sidebar 2026-03-05 23:44:00 +00:00
Frank 463180fe3d zen: gpt 5.4 pro 2026-03-05 18:39:17 -05:00
Frank db90149ea1 ci: fix 2026-03-05 15:46:17 -05:00
Frank d5c9cb7566 zen: gpt5.3 codex spark 2026-03-05 15:40:51 -05:00
Jun ec77b3cb1a fix(console): follow-up for #13108 docs/en routing and locale cookie sync (#13608) 2026-03-05 14:31:38 -06:00
Adam 6be25b332c fix(app): load tab on open file 2026-03-05 13:40:25 -06:00
opencode-agent[bot] 054619aede docs(i18n): sync locale docs from english changes 2026-03-05 19:01:10 +00:00
Frank f8f051cdda zen: gpt5.4 2026-03-05 19:01:10 +00:00
opencode e772e618d8 release: v1.2.18 2026-03-05 19:01:03 +00:00
opencode-agent[bot] 4ccafbc0ff chore: update nix node_modules hashes 2026-03-05 15:09:14 +00:00
Dax e0127541a6 feat(tui): add onClick handler to InlineTool and Task components (#16187) 2026-03-05 15:02:30 +00:00
Adam adefbd022f chore(app): ghostty-web fork 2026-03-05 08:58:11 -06:00
Dax Raad 061001b601 ci: remove unused publishConfig that was breaking npm publishing 2026-03-05 09:45:30 -05:00
Dax Raad 42cec1212d update sdk package.json 2026-03-05 09:45:30 -05:00
Adam b72468d5fc fix(app): stale keyed show errors 2026-03-05 08:42:50 -06:00
Adam 4f4f040751 fix(app): locale error 2026-03-05 08:10:36 -06:00
Adam a27ea86940 fix(app): stale read error 2026-03-05 08:09:29 -06:00
Adam c4208ff5f7 fix(app): websearch and codesearch tool rendering 2026-03-05 08:00:42 -06:00
Adam 6dc718265a fix(app): provider settings consistency 2026-03-05 08:00:42 -06:00
Adam 11e7928ee0 fix(app): file icon stability 2026-03-05 08:00:42 -06:00
Adam 7bc431939c fix(app): file path truncation in session turn 2026-03-05 08:00:42 -06:00
Adam 7378b9487b fix(app): file icon stability 2026-03-05 08:00:41 -06:00
ismeth b1cc87e0c1 fix(app): preserve question dock state across session switches (#16173) 2026-03-05 19:05:16 +05:30
Adam f0e9fb9211 fix(app): icon jiggle 2026-03-05 07:00:21 -06:00
Adam 1398e42e38 chore(console): go page i18n 2026-03-05 06:59:04 -06:00
Adam 29e1711e6c fix(app): mod+f always opens search 2026-03-05 06:57:50 -06:00
Adam 92d9f224d1 fix(app): no delay on tooltip close 2026-03-05 06:44:11 -06:00
OpeOginni b38173c537 refactor(desktop): improve error handling and translation in server error formatting (#16171) 2026-03-05 06:28:17 -06:00
opencode-agent[bot] d109d853bc chore: generate 2026-03-05 12:27:12 +00:00
OpeOginni 4e9b789667 fix(app): improve agent selection logic passing in configured models and variants correctly (#16072) 2026-03-05 06:26:20 -06:00
Shoubhit Dash 81ff804a8d desktop: remove unnecessary macOS entitlements (#16161) 2026-03-05 05:43:21 -06:00
Brendan Allan f5b6bc0de1 desktop: new-session deeplink (#15322) 2026-03-05 09:15:14 +00:00
Brendan Allan 4db91c973d desktop-electon: handle latest version update check properly 2026-03-05 17:00:13 +08:00
opencode-agent[bot] 9d7cebafe3 chore: update nix node_modules hashes 2026-03-05 06:53:43 +00:00
Brendan Allan ef5a68543b app: prefer using useLocation instead of window.location (#15989) 2026-03-05 14:41:12 +08:00
Daniel Polito bc4aa7f5e5 feat(opencode): Adding options to auth login to skip questions (#14470)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-05 06:16:53 +00:00
Frank 8b92f6d7aa zen: update go page 2026-03-04 22:23:22 -05:00
Anton Volkov 08cc307b4d chore: update turborepo (#16061) 2026-03-05 09:49:11 +10:00
Luke Parker fd7634ebee dont let dax touch the ui (#16060) 2026-03-04 23:31:48 +00:00
Frank ada0ec009c wip: zen 2026-03-04 18:29:38 -05:00
James Long 59eb5322a0 fix(core): handle SIGHUP and kill process (#16057) 2026-03-04 23:12:05 +00:00
Frank a178477eb8 Merge branch 'go-page' into dev 2026-03-04 18:00:11 -05:00
David Hill c298aed94c tui: prevent Go pricing graph from overflowing on medium screens by constraining width and moving axis labels outside SVG for sharper rendering 2026-03-04 22:31:23 +00:00
David Hill 39d6d36a70 tui: clearer graph labels and responsive layout for usage visualization
Improve readability of the usage graph y-axis label by spelling out
'Requests per 5 hour' instead of the abbreviated 'Requests/5h'. Fix
layout on smaller screens by removing negative margin that was
causing the graph to overflow its container.
2026-03-04 22:19:28 +00:00
David Hill b046d25c14 tui: clarify free tier includes Big Pickle and promotional requests on Go pricing page 2026-03-04 22:16:30 +00:00
David Hill fa61ed7c63 wip: Make bar colors in limit graph customizable via CSS variables for consistent theming across the go route visualization 2026-03-04 22:10:17 +00:00
David Hill 5a80a3d465 docs: redesign Go pricing graph with horizontal bars and inline request labels
Improve visual clarity of request limits on the Go pricing page by replacing
dot-based chart with animated horizontal bars that directly show model names
and exact request counts. Add proper OpenGraph and Twitter Card meta tags for
better social sharing discovery.
2026-03-04 22:07:49 +00:00
opencode-agent[bot] 9883d17ac1 docs(i18n): sync locale docs from english changes 2026-03-04 18:39:59 +00:00
Scott Tolinski 37fb3a3102 docs: Add opencode-sentry-monitor to ecosystem documentation (#16037) 2026-03-04 13:34:17 -05:00
David Hill 45072f5ba1 ci: make tsgo available for pre-push typechecks 2026-03-04 16:48:29 +00:00
David Hill 37f8625be3 chore(ui): remove quotes 2026-03-04 16:45:34 +00:00
David Hill 0c3bee1450 tweak(ui): darker text 2026-03-04 16:43:43 +00:00
David Hill 7d5d3a3e2c tweak(ui): remove section 2026-03-04 16:42:30 +00:00
David Hill 23902e7af9 tui: show Go request limits per 5-hour session 2026-03-04 16:35:03 +00:00
David Hill b139965bc2 tui: clarify which models are available in Go subscription
Adds list of included AI models (GLM-5, Kimi K2.5, and MiniMax M2.5) to the Go page so users know exactly what model access their subscription provides
2026-03-04 16:19:06 +00:00
Frank 1541868d36 Merge branch 'dev' into go-page 2026-03-04 11:13:14 -05:00
David Hill 9a49f46fb0 tui: reduce excessive spacing in go route layout to improve visual balance 2026-03-04 16:09:51 +00:00
David Hill f71f5f1391 docs: make Go hero CTA translatable with pricing emphasis 2026-03-04 16:02:37 +00:00
David Hill b9ce65e2ad docs: localize Go graph and testimonial copy 2026-03-04 15:58:58 +00:00
David Hill 6066ae3a66 docs: de-link Go testimonials and swap Zen→Go 2026-03-04 15:49:45 +00:00
David Hill c6e46e701a docs: update Go privacy copy for global hosting 2026-03-04 15:47:53 +00:00
David Hill 2b70c8c049 docs: clarify Go models in FAQ answer 2026-03-04 15:24:51 +00:00
David Hill d427fc4435 docs: replace Go landing page video with interactive limits graph
Users can now see a clear visual comparison of request limits between Free tier and Go tier across all available models, making it easier to understand the value of a Go subscription at a glance.
2026-03-04 15:21:07 +00:00
opencode 11a575ece6 release: v1.2.17 2026-03-04 14:58:04 +00:00
Brendan Allan b2b29ac148 ci: remove electron beta requirement 2026-03-04 22:38:23 +08:00
Brendan Allan f82d73e2b9 ci: only publish electron on beta 2026-03-04 22:21:56 +08:00
Dax Raad 09904ef0ad remove build from typecheck 2026-03-04 09:20:24 -05:00
Sebastian 2f97b14e05 show scrollbar by default (#15282) 2026-03-04 15:10:27 +01:00
Adam fbb0a8273a fix(app): remove blur from todos 2026-03-04 07:43:28 -06:00
Adam a33f80d486 fix(app): delay dock animation on session load 2026-03-04 07:39:34 -06:00
Adam 234078addd fix(app): remove diff lines from sessions in sidebar 2026-03-04 07:36:10 -06:00
David Hill f3846adff3 chore(console): update copy 2026-03-04 13:24:08 +00:00
Adam 3e071c30ee fix(app): loading session should be scrolled to the bottom 2026-03-04 07:18:09 -06:00
David Hill 315f23519b chore(console): edit copy 2026-03-04 13:08:21 +00:00
David Hill 0e4a81fc11 docs: send Go landing page links to Go docs 2026-03-04 13:05:19 +00:00
Adam a276c2a79a fix(app): terminal tab close 2026-03-04 07:04:03 -06:00
David Hill 1d4405fe04 chore(console): switch /go page to go.* i18n keys 2026-03-04 12:57:49 +00:00
David Hill 85917abfb1 chore(console): use Go ornate logo on /go 2026-03-04 12:15:40 +00:00
David Hill 03a7629cbd chore(console): refine /go hero and pricing copy 2026-03-04 12:01:15 +00:00
David Hill 8e529da4fd chore(console): hide Go nav item on /go 2026-03-04 11:46:30 +00:00
David Hill 61714a6255 chore(console): update /go hero body 2026-03-04 11:34:44 +00:00
opencode-agent[bot] 7e2c6a8527 docs(i18n): sync locale docs from english changes 2026-03-04 11:27:41 +00:00
David Hill 5d5d01d985 chore(console): move login to end on zen/go 2026-03-04 11:26:50 +00:00
shivam kr chaudhary b644c528cc fix(docs): update dead opencode-daytona ecosystem link (#15979)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-04 11:18:28 +00:00
Brendan Allan db0e0b1e2d ui: rely on task part href instead of onClick handler (#15978) 2026-03-04 16:41:20 +08:00
opencode-agent[bot] 8312acc19b chore: update nix node_modules hashes 2026-03-04 07:35:19 +00:00
Brendan Allan dffd2d0ae4 desktop: add electron version (#15663) 2026-03-04 15:12:34 +08:00
Frank 5dc630ea80 zen: fix aws bedrock header 2026-03-03 23:45:43 -05:00
Dax 2bb68320ac fix(process): prevent orphaned opencode subprocesses on shutdown (#15924) 2026-03-03 22:14:28 -05:00
opencode-agent[bot] 4f1d8293cf chore: generate 2026-03-04 02:36:18 +00:00
James Long 113baa876e feat(core): rework workspace integration and adaptor interface (#15895) 2026-03-03 21:35:38 -05:00
Dax 48b7e8a908 docs(bash): clarify output capture guidance (#15928) 2026-03-03 21:10:26 -05:00
Andrea Alberti f85648c660 fix: run --attach agent validation (#11812)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-03 22:30:16 +00:00
Copilot d63e724e68 revert: undo turbo typecheck dependency change from #14828 (#15902)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-03-04 07:35:24 +10:00
opencode 57d918be0c release: v1.2.16 2026-03-03 21:08:35 +00:00
Dax Raad a92b065107 tui: replace curved arrow with straight arrow for better terminal compatibility 2026-03-03 15:25:11 -05:00
Dax Raad 6e752828dd tui: show pending toolcall count instead of generic 'Running...' message 2026-03-03 15:08:11 -05:00
Dax dc479bdf5f tui: use arrow indicator for active tool execution (#15887) 2026-03-03 14:46:07 -05:00
David Hill 759a3ccfb3 chore(console): trim /go model logos 2026-03-03 19:04:48 +00:00
David Hill c35d20f0e6 chore(console): update /go hero copy 2026-03-03 19:01:24 +00:00
David Hill 6a7513600c chore(console): move Enterprise after Go 2026-03-03 18:51:53 +00:00
Matt Silverlock 4e510bd94f fix(auth): normalize trailing slashes in auth login URLs (#15874) 2026-03-03 13:35:49 -05:00
Frank 7c6e6e8af1 wip: zen 2026-03-03 13:30:34 -05:00
David Hill 2d71185b86 chore(console): add Go to nav 2026-03-03 18:30:04 +00:00
David Hill e1520e89ed feat(console): add /go landing page 2026-03-03 18:29:42 +00:00
Adam ce8629c506 fix(app): defer diff rendering 2026-03-03 11:14:03 -06:00
Adam 4e7ef5e08b fix(app): defer diff rendering 2026-03-03 11:00:06 -06:00
Adam 6d62be48d0 chore: cleanup 2026-03-03 10:25:49 -06:00
opencode-agent[bot] 266735929c chore: update nix node_modules hashes 2026-03-03 15:52:03 +00:00
Sebastian 8ade9e4e30 Upgrade opentui to v0.1.86 and activate markdown renderable by default (#14974) 2026-03-03 15:42:27 +00:00
opencode-agent[bot] 3d36c06659 docs(i18n): sync locale docs from english changes 2026-03-03 15:03:53 +00:00
Frank 87a9b477e6 wip: zen 2026-03-03 09:44:34 -05:00
Adam db728098bc fix(app): timeline jank 2026-03-03 08:28:56 -06:00
Adam 21a10a5ccf fix(app): tighten up header elements 2026-03-03 07:19:24 -06:00
Adam 78d63d9062 chore: cleanup 2026-03-03 07:10:52 -06:00
Adam d5f54bf94e chore: tr glossary 2026-03-03 07:10:52 -06:00
opencode-agent[bot] b9f2f59fd1 chore: generate 2026-03-03 12:28:35 +00:00
İbrahim Hakkı Ergin eabf8b4202 fix: update Turkish translations (#15835) 2026-03-03 06:27:54 -06:00
Jack f746f38c79 fix(app): stabilize project close navigation (#15817)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-03 06:25:55 -06:00
Adam 5728c25c00 chore: cleanup 2026-03-03 06:20:48 -06:00
Adam 18accb3326 chore: fix test 2026-03-03 05:53:03 -06:00
Adam f9fcdc4102 fix(app): timeline jank 2026-03-03 05:35:15 -06:00
Adam 44aaba9a17 fix(app): timeline jank 2026-03-03 05:35:15 -06:00
Jérôme Benoit 71bfe2487e fix(opencode): disable session navigation commands when no parent session (#15762)
Co-authored-by: Test User <test@test.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-03 16:00:25 +05:30
Caleb Norton 5cc07df342 chore: nix flake update for bun 1.3.10 (#15648)
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
2026-03-03 12:16:43 +05:00
Frank 5f069421a1 zen: docs 2026-03-03 01:27:55 -05:00
Frank 425b6b2ede wip: zen 2026-03-03 01:00:50 -05:00
Frank da845488a5 wip: zen 2026-03-03 00:52:12 -05:00
opencode-agent[bot] b6c82d5b5b chore: generate 2026-03-03 05:26:22 +00:00
Frank 1f33b986e4 wip: zen 2026-03-03 00:25:03 -05:00
opencode-agent[bot] a0be4ad767 chore: generate 2026-03-03 04:41:52 +00:00
Shoubhit Dash eeaf8b888e fix(opencode): avoid gemini combiner schema sibling injection (#15318) 2026-03-03 10:11:05 +05:30
opencode-agent[bot] 1409bd2df3 chore: generate 2026-03-03 03:53:50 +00:00
Ryan Skidmore 2bcf5e371c fix(provider): forward metadata options to cloudflare-ai-gateway provider (#15619) 2026-03-02 22:53:03 -05:00
Ryan Skidmore 02603fef14 fix(opencode): clone part data in Bus event to preserve token values (#15780) 2026-03-02 22:52:43 -05:00
opencode-agent[bot] 580f19ad1c chore: update nix node_modules hashes 2026-03-02 22:38:45 +00:00
opencode-agent[bot] 8562463036 chore: generate 2026-03-02 22:25:39 +00:00
Kit Langton 848b3d658e Animation Smorgasbord (#15637)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-02 16:24:32 -06:00
Adam b84ebce1fc fix(app): default auto-respond to false 2026-03-02 13:10:37 -06:00
Adam 9c52e2b4cb chore: fix docs sync permissions 2026-03-02 11:42:20 -06:00
Adam a08c524902 chore(app): solidjs refactoring (#13399) 2026-03-02 10:50:50 -06:00
David Hill e597187254 ui: move session review bottom padding
Remove bottom padding from the scroll wrapper and apply it to the accordion content instead.
2026-03-02 16:19:35 +00:00
David Hill 29adce71de ui: avoid session review header clipping
Move the session review header outside the scroll viewport and drop strict containment so shadows can render without being cropped.
2026-03-02 16:05:16 +00:00
David Hill cbc865ebe7 ui: tighten scroll thumb and review padding
Keep the scroll thumb visually slim (4px) while preserving a 12px drag target, and remove extra right padding in session review content.
2026-03-02 15:37:41 +00:00
David Hill 01f184c8ed ui: widen scroll thumb hit area
Make the thumb overlay 12px wide while keeping the visible bar 6px centered for easier hover/drag.
2026-03-02 15:26:55 +00:00
David Hill 34e88a2ecf core: keep review header buttons visible when scroll thumb shows 2026-03-02 14:59:12 +00:00
Filip 5657c76fa6 feat(app): show which messages are queued (#15587) 2026-03-02 13:27:34 +05:30
opencode-agent[bot] 2200846808 chore: generate 2026-03-02 07:41:53 +00:00
bentrd 6ccc8b01f7 fix: recover from 413 Request Entity Too Large via auto-compaction (#14707)
Co-authored-by: Noam Bressler <noamzbr@gmail.com>
2026-03-02 13:10:55 +05:30
Noam Bressler 7e1a425e6a fix: project ID conflict, and update on same session id (#15596) 2026-03-02 13:09:53 +05:30
Brendan Allan b3ce2e0390 desktop: fix latest.json finalizer 2026-03-02 14:36:35 +08:00
Shoubhit Dash eefaa49ef8 Revert "fix(i18n): polish turkish translations" (#15656) 2026-03-02 06:03:50 +00:00
Brendan Allan fc8b415706 desktop: use correct download link in finalize-latest-json 2026-03-02 11:21:17 +08:00
opencode-agent[bot] 6a7f159779 chore: generate 2026-03-01 19:17:56 +00:00
Kit Langton 2fef1e912c perf(session): faster session switching via windowed rendering and staged timeline (#15474) 2026-03-01 13:17:04 -06:00
Frank 977c938536 doc: add zen deprecated models 2026-03-01 13:21:34 -05:00
Dax a308132036 feat(tui): improve task tool display with subagent keybind hints and spinner animations (#15607) 2026-03-01 17:46:10 +00:00
Rian van der Merwe e55c250aa5 fix(opencode): show human-readable message for HTML error responses (#15407) 2026-03-01 09:24:57 -06:00
Filip 5515cd3242 feat(app): add compact ui (#15578) 2026-03-01 08:41:47 -06:00
Filip 8d4aa4b80e fix(app): make provider icon resolved id reactive (#15583) 2026-03-01 08:40:04 -06:00
Uğur Murat Altıntas a765489e36 fix(i18n): polish turkish translations (#15491) 2026-03-01 06:48:26 -06:00
Filip f6caa856b6 fix(app): show proper usage limit errors (#15496) 2026-03-01 06:48:11 -06:00
ryanwyler 149f21f42d fix: kill orphaned MCP child processes and expose OPENCODE_PID on shu… (#15516) 2026-03-01 18:08:17 +05:30
opencode-agent[bot] 72b7b0545f chore: generate 2026-03-01 04:44:37 +00:00
inkdust2021 797ffabdd5 docs(ecosystem): add opencode-vibeguard (#15464) 2026-03-01 10:13:52 +05:30
opencode-agent[bot] 3721849168 chore: generate 2026-03-01 03:10:46 +00:00
James Long e34419afc8 feat(core): add workspace_id to session table (#15410) 2026-02-28 22:09:53 -05:00
opencode-agent[bot] d1025def50 chore: generate 2026-03-01 01:45:44 +00:00
James Long 40723568ef feat(core): add WorkspaceContext (#15409) 2026-02-28 20:44:54 -05:00
Dax Raad 880d7bd656 docs: fix broken config imports in translated documentation
Fixed incorrect relative import paths in Bosnian, French, Italian,
Korean, Norwegian, Portuguese, Turkish, and Chinese docs that were
referencing config.mjs from the wrong directory level. This resolves
build errors when viewing translated documentation pages.
2026-02-28 18:54:18 -05:00
Adam d7334fe4e5 chore(docs): i18n sync (#15417) 2026-02-28 15:27:11 -06:00
mridul ee008b99a3 fix(app): fallback to synthetic icon for unknown provider IDs (#15295) 2026-02-28 15:13:23 -06:00
Alex Yaroshuk e135277228 fix(app): display skill name in skill tool call (#15413) 2026-02-27 19:18:14 -06:00
Adam c97fd4b692 chore: cleanup 2026-02-27 19:12:19 -06:00
Jay ccd5717c0c docs: Update OpenCode Go subscription and usage details (#15415) 2026-02-27 16:04:53 -08:00
Frank 185552ce0e doc: go 2026-02-27 18:03:39 -05:00
Frank 20a2c796b5 doc: opencode go 2026-02-27 17:38:30 -05:00
opencode-agent[bot] 562b3af498 chore: update nix node_modules hashes 2026-02-27 21:59:23 +00:00
Adam cbe364db32 chore(console): i18n sync (#15360) 2026-02-27 15:50:50 -06:00
Adam 406885abd3 chore(app): deps 2026-02-27 15:49:38 -06:00
opencode-agent[bot] af88d19018 chore: generate 2026-02-27 21:17:37 +00:00
Alex Yaroshuk 31708751b8 fix(app): make bash output selectable (#15378) 2026-02-27 15:16:33 -06:00
shivam kr chaudhary 903fe5d6bb docs: add missing Bosanski link to Arabic README (#15399) 2026-02-27 15:15:48 -06:00
opencode-agent[bot] 19fcdf46fe chore: generate 2026-02-27 20:37:28 +00:00
James Long a5c224f2ae feat(core): basic implementation of remote workspace support (#15120) 2026-02-27 15:36:39 -05:00
Adam feb6a247ea fix(app): scroll issues 2026-02-27 09:47:56 -06:00
Adam 8fbb7bba36 chore(app): i18n sync (#15362) 2026-02-27 09:45:00 -06:00
Adam 39350b7429 chore: update translator model 2026-02-27 09:27:12 -06:00
Adam f51f3a8bb5 chore: update docs locale sync workflow 2026-02-27 09:18:17 -06:00
Kit Langton bfa4f1978b feat(app): add Warp to the open menu (#15368) 2026-02-27 09:44:01 -05:00
Kit Langton 1bbed4526a chore(script): source team members from TEAM_MEMBERS (#15369) 2026-02-27 09:43:51 -05:00
James Long 039b68e12d Change keybindings to navigate between child sessions (#14814) 2026-02-27 09:41:23 -05:00
Adam 90cc1400ed chore: update test 2026-02-27 06:54:15 -06:00
Brendan Allan e9057b39b5 desktop: add latest.json finalizer script (#15335) 2026-02-27 20:18:21 +08:00
Adam 410c8dc5e8 fix(app): auto-accept permissions 2026-02-27 06:17:40 -06:00
Adam db7a8218cd fix(app): update provider sprite 2026-02-27 06:01:00 -06:00
opencode-agent[bot] ccb5edd693 chore: generate 2026-02-27 11:41:55 +00:00
vaur94 4ea76090ce feat(app): add Turkish (tr) locale for app and ui packages (#15278)
Co-authored-by: Ugur <ugur@example.com>
2026-02-27 05:41:06 -06:00
Pirro Zani c033104007 docs(readme): add Greek translation and update language navigation (#15281) 2026-02-27 05:39:56 -06:00
Filip 06aee9d60e feat(app): recent projects section in command pallette (#15270) 2026-02-27 05:33:22 -06:00
Frank 9ac2938922 wip: zen 2026-02-27 02:56:43 -05:00
Brendan Allan 3651b6ca7c desktop: move open_path to rust (#15323) 2026-02-27 06:17:24 +00:00
Frank bb5bd0346b wip: zen 2026-02-27 00:30:14 -05:00
Brendan Allan 9182513e03 app: allow providing username and password when connecting to remote server (#14872) 2026-02-27 13:26:15 +08:00
Frank 3db0159453 zen: fix go plan usage limit 2026-02-26 23:20:05 -05:00
Adam 99d43f72de fix(app): scroll jacking 2026-02-26 20:41:35 -06:00
Adam c86ef19da5 fix(app): permission indicator 2026-02-26 20:40:39 -06:00
Adam 1f416ba16f fix(app): permission notifications 2026-02-26 20:36:10 -06:00
David Hill 55feba49f8 tweak(app): show keybind on context tab close 2026-02-27 00:29:02 +00:00
Adam 4a37b76340 feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-02-26 18:23:04 -06:00
Kit Langton 32fa0b40d7 refactor(app): dedupe filetree scroll state 2026-02-26 19:20:56 -05:00
David Hill 6e70bf75ed tweak(app): align review changes select height 2026-02-27 00:18:14 +00:00
David Hill 6c131eb34b fix(ui): mute inactive file tab icons 2026-02-27 00:18:14 +00:00
David Hill 44c10a5931 tweak(ui): max-width on session when the review is closed but the file tree is open 2026-02-27 00:18:14 +00:00
Adam 8e647926a4 fix: test 2026-02-26 18:11:05 -06:00
Adam 72793481ee Revert "fix(ui): prevent filename and diff count overlap in session changes (#14773)"
This reverts commit 007788ec9f.
2026-02-26 18:11:05 -06:00
David Hill c466e0ac3a tweak(ui): add border to filetree on scroll 2026-02-26 23:18:35 +00:00
David Hill 5211e969dc tui: fix session tab alignment in compact view to prevent vertical overflow 2026-02-26 22:54:49 +00:00
opencode-agent[bot] 01800334d0 chore: update nix node_modules hashes 2026-02-26 22:18:33 +00:00
Adam 073fc8b3dc chore: storybook (#15285)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-02-26 16:05:04 -06:00
Adam 60ca2c4bf0 fix(app): terminal issues 2026-02-26 15:52:35 -06:00
Shoubhit Dash 007788ec9f fix(ui): prevent filename and diff count overlap in session changes (#14773) 2026-02-26 15:42:24 -06:00
Shoubhit Dash 26e1174b02 fix(ui): avoid truncating workspace paths in assistant text (#14584) 2026-02-26 15:41:58 -06:00
Adam d0020d4f8f feat(app): new tabs styling (#15284)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-02-26 15:39:45 -06:00
Adam b9f7ea0442 feat(app): auto-accept all permissions mode 2026-02-26 15:23:26 -06:00
OpeOginni 75879315fb refactor(desktop): enhance project tile interaction with suppress hover functionality (#15214) 2026-02-26 11:00:11 -06:00
Niu Shuai f055903464 docs: Sync zh_CN docs with English Version (#15228) 2026-02-26 10:59:45 -06:00
Frank 01f319fb56 wip: zen 2026-02-26 11:17:19 -05:00
adamelmore 3cc7a6c255 chore: fix flaky test 2026-02-26 08:53:40 -06:00
Adam 5724c58d24 fix(app): simplify review layout (#14953)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
2026-02-26 08:51:58 -06:00
David Hill e00660647a feat: opencode go provider list (#15203) 2026-02-26 14:37:33 +00:00
Shoubhit Dash daaddc084b fix(desktop): restore shell path env for desktop sidecar (#15211) 2026-02-26 18:35:21 +05:30
Filip b1596dca43 fix(app): open in powershell (#15112) 2026-02-26 16:39:55 +08:00
opencode 941f7ef778 release: v1.2.15 2026-02-26 08:22:25 +00:00
Luke Parker 3409375fd6 fix: most segfaults on windows with Bun v1.3.10 stable (#15181) 2026-02-26 17:55:01 +10:00
Stefan 27f440caa8 fix(app): middle-click tab close in scrollable tab bar (#15081)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-02-26 12:29:08 +05:30
kil-penguin 1a2e4f419c fix(desktop): remove interactive shell flag from sidecar spawn to prevent hang on macOS (#15136)
Co-authored-by: kil-penguin <hyeonjun@gameduo.net>
2026-02-26 14:02:40 +08:00
Frank c73be95e88 wip: zen 2026-02-25 23:06:16 -05:00
Adam 4e6d2907cc fix(app): permissions and questions from child sessions (#15105)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
2026-02-26 01:05:08 +00:00
OpeOginni 9ddeee914b fix(docs): update schema URL in share configuration examples across multiple languages (#15114) 2026-02-25 18:16:39 -05:00
opencode-agent[bot] 270424448f chore: generate 2026-02-25 22:55:09 +00:00
Sebastian e0d78a4445 split tui/server config (#13968) 2026-02-25 23:53:09 +01:00
Frank 0c5e2b5fef wip: zen go 2026-02-25 12:39:50 -05:00
adamelmore b73992034a chore: move glossary 2026-02-25 10:29:05 -06:00
David Hill c31d02046e tweak(ui): keep reasoning inline code subdued in dark mode 2026-02-25 15:18:05 +00:00
Filip 9666a5af1a fix(app): keyboard navigation previous/next message (#15047) 2026-02-25 08:57:13 -06:00
Oleksii Pavliuk 249174de94 fix(app): correct Copilot provider description in i18n files (#15071) 2026-02-25 14:56:03 +00:00
Ryan Vogel 6ec0996134 chore(workflows): label vouched users and restrict vouch managers (#15075) 2026-02-25 14:56:03 +00:00
opencode-agent[bot] 641f2f35c9 chore: generate 2026-02-25 14:56:03 +00:00
opencode d9b15a31f6 release: v1.2.14 2026-02-25 14:55:56 +00:00
Shantur Rathore a19e39dd5a feat(core): add message delete endpoint (#14417) 2026-02-25 09:25:26 -05:00
Ayush Thakur 4510f17ef5 fix: consume stdout concurrently with process exit in auth login (#15058) 2026-02-25 08:22:52 -05:00
opencode 4fe679b14c release: v1.2.13 2026-02-25 07:27:19 +00:00
1600 changed files with 104550 additions and 34811 deletions
+4
View File
@@ -10,6 +10,9 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
-florianleibert
fwang
@@ -17,6 +20,7 @@ iamdavidhill
jayair
kitlangton
kommander
-opencode2026
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
+26 -61
View File
@@ -1,32 +1,20 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
cross-compile:
description: "Pre-cache canary cross-compile binaries for all targets"
required: false
default: "false"
runs:
using: "composite"
steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Get baseline download URL
id: bun-url
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/canary/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
@@ -35,54 +23,31 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Pre-cache canary cross-compile binaries
if: inputs.cross-compile == 'true'
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
run: |
BUN_VERSION=$(bun --revision)
if echo "$BUN_VERSION" | grep -q "canary"; then
SEMVER=$(echo "$BUN_VERSION" | sed 's/^\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/')
echo "Bun version: $BUN_VERSION (semver: $SEMVER)"
CACHE_DIR="$HOME/.bun/install/cache"
mkdir -p "$CACHE_DIR"
TMP_DIR=$(mktemp -d)
for TARGET in linux-aarch64 linux-x64 linux-x64-baseline linux-aarch64-musl linux-x64-musl linux-x64-musl-baseline darwin-aarch64 darwin-x64 windows-x64 windows-x64-baseline; do
DEST="$CACHE_DIR/bun-${TARGET}-v${SEMVER}"
if [ -f "$DEST" ]; then
echo "Already cached: $DEST"
continue
fi
URL="https://github.com/oven-sh/bun/releases/download/canary/bun-${TARGET}.zip"
echo "Downloading $TARGET from $URL"
if curl -sfL -o "$TMP_DIR/bun.zip" "$URL"; then
unzip -qo "$TMP_DIR/bun.zip" -d "$TMP_DIR"
if echo "$TARGET" | grep -q "windows"; then
BIN_NAME="bun.exe"
else
BIN_NAME="bun"
fi
mv "$TMP_DIR/bun-${TARGET}/$BIN_NAME" "$DEST"
chmod +x "$DEST"
rm -rf "$TMP_DIR/bun-${TARGET}" "$TMP_DIR/bun.zip"
echo "Cached: $DEST"
# baseline bun resolves "bun-darwin-x64" to the baseline cache key
# so copy the modern binary there too
if [ "$TARGET" = "darwin-x64" ]; then
BASELINE_DEST="$CACHE_DIR/bun-darwin-x64-baseline-v${SEMVER}"
if [ ! -f "$BASELINE_DEST" ]; then
cp "$DEST" "$BASELINE_DEST"
echo "Cached (baseline alias): $BASELINE_DEST"
fi
fi
else
echo "Skipped: $TARGET (not available)"
fi
done
rm -rf "$TMP_DIR"
else
echo "Not a canary build ($BUN_VERSION), skipping pre-cache"
fi
- 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
+24
View File
@@ -0,0 +1,24 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
+11 -44
View File
@@ -47,60 +47,26 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Install OpenCode
if: steps.changes.outputs.has_changes == 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Sync locale docs with OpenCode
if: steps.changes.outputs.has_changes == 'true'
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CONFIG_CONTENT: |
{
"permission": {
"*": "deny",
"read": {
"*": "deny",
"packages/web/src/content/docs": "allow",
"packages/web/src/content/docs/*": "allow",
"packages/web/src/content/docs/*.mdx": "allow",
"packages/web/src/content/docs/*/*.mdx": "allow",
".opencode": "allow",
".opencode/agent": "allow",
".opencode/agent/glossary": "allow",
".opencode/agent/translator.md": "allow",
".opencode/agent/glossary/*.md": "allow"
},
"edit": {
"*": "deny",
"packages/web/src/content/docs/*/*.mdx": "allow"
},
"glob": {
"*": "deny",
"packages/web/src/content/docs*": "allow",
".opencode/agent/glossary*": "allow"
},
"task": {
"*": "deny",
"translator": "allow"
}
},
"agent": {
"translator": {
"permission": {
"*": "deny",
"read": {
"*": "deny",
".opencode/agent/translator.md": "allow",
".opencode/agent/glossary/*.md": "allow"
}
}
}
"read": "allow",
"edit": "allow",
"glob": "allow",
"task": "allow"
}
}
with:
model: opencode/gpt-5.3-codex
agent: docs
use_github_token: true
prompt: |
run: |
opencode run --agent docs --model opencode/gpt-5.3-codex <<'EOF'
Update localized docs to match the latest English docs changes.
Changed English doc files:
@@ -118,6 +84,7 @@ jobs:
7. Keep locale docs structure aligned with their corresponding English pages.
8. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
9. If no locale updates are needed, make no changes.
EOF
- name: Commit and push locale docs updates
if: steps.changes.outputs.has_changes == 'true'
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
+139 -4
View File
@@ -77,8 +77,6 @@ jobs:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
cross-compile: "true"
- name: Setup git committer
id: committer
@@ -90,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts --all
./packages/opencode/script/build.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -101,7 +99,6 @@ jobs:
with:
name: opencode-cli
path: packages/opencode/dist
outputs:
version: ${{ needs.version.outputs.version }}
@@ -118,6 +115,9 @@ jobs:
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
@@ -152,6 +152,10 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
@@ -242,11 +246,135 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
build-electron:
needs:
- build-cli
- version
continue-on-error: false
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
- host: "blacksmith-4vcpu-ubuntu-2404"
target: x86_64-unknown-linux-gnu
platform_flag: --linux
- host: "blacksmith-4vcpu-ubuntu-2404"
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
- uses: apple-actions/import-codesign-certs@v2
if: runner.os == 'macOS'
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Apple API Key
if: runner.os == 'macOS'
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
- name: Install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
sudo chmod -R a+rw ~/apt-cache
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Prepare
run: bun ./scripts/prepare.ts
working-directory: packages/desktop-electron
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Package and publish
if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Package (no publish)
if: ${{ !needs.version.outputs.release }}
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
if: needs.version.outputs.release
with:
name: latest-yml-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/latest*.yml
publish:
needs:
- version
- build-cli
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -283,6 +411,12 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Cache apt packages (AUR)
uses: actions/cache@v4
with:
@@ -310,3 +444,4 @@ jobs:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
+1 -3
View File
@@ -20,12 +20,10 @@ jobs:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
cross-compile: "true"
- name: Build
run: |
./packages/opencode/script/build.ts --all
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
-33
View File
@@ -1,33 +0,0 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+38
View File
@@ -0,0 +1,38 @@
name: storybook
on:
push:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
pull_request:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: storybook build
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Build Storybook
run: bun --cwd packages/storybook build
+32 -21
View File
@@ -6,6 +6,16 @@ on:
- dev
pull_request:
workflow_dispatch:
concurrency:
# Keep every run on dev so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
permissions:
contents: read
jobs:
unit:
name: unit (${{ matrix.settings.name }})
@@ -40,20 +50,17 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
@@ -66,9 +73,28 @@ jobs:
- name: 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
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
run: bun --cwd packages/app test:e2e:local
@@ -86,18 +112,3 @@ jobs:
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"
+39 -19
View File
@@ -42,15 +42,17 @@ jobs:
throw error;
}
// Parse the .td file for denounced users
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const rest = trimmed.slice(1).trim();
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
@@ -65,32 +67,50 @@ jobs:
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
denounced.set(username.toLowerCase(), reason);
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason === undefined) {
core.info(`User ${author} is not denounced. Allowing issue.`);
if (reason !== undefined) {
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return;
}
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.createComment({
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
labels: ['Vouched'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
+38 -17
View File
@@ -6,6 +6,7 @@ on:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
@@ -42,15 +43,17 @@ jobs:
throw error;
}
// Parse the .td file for denounced users
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!trimmed.startsWith('-')) continue;
const rest = trimmed.slice(1).trim();
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
@@ -65,29 +68,47 @@ jobs:
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
denounced.set(username.toLowerCase(), reason);
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason === undefined) {
core.info(`User ${author} is not denounced. Allowing PR.`);
if (reason !== undefined) {
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return;
}
// Author is denounced — close the PR
await github.rest.issues.createComment({
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
labels: ['Vouched'],
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
@@ -33,5 +33,6 @@ jobs:
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+1 -1
View File
@@ -17,7 +17,7 @@ ts-dist
/result
refs
Session.vim
opencode.json
/opencode.json
a.out
target
.scripts
+5 -2
View File
@@ -1,3 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
bun.lock
.gitignore
package-lock.json
+2 -2
View File
@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3.1-pro
model: opencode/gpt-5.4
---
You are a professional translator and localization specialist.
@@ -13,7 +13,7 @@ Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Also apply locale-specific guidance from `.opencode/agent/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).
+21
View File
@@ -0,0 +1,21 @@
---
model: opencode/kimi-k2.5
---
create UPCOMING_CHANGELOG.md
it should have sections
```
# TUI
# Desktop
# Core
# Misc
```
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 into the appropriate section.
+38
View File
@@ -0,0 +1,38 @@
# tr Glossary
## Sources
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose, docs, and UI copy)
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
## Guidance
- Prefer natural Turkish phrasing over literal translation
- Merge broken sentence fragments into one clear sentence when the source is a single thought
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
## Avoid
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
+5
View File
@@ -5,6 +5,11 @@
"options": {},
},
},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
},
},
"mcp": {},
"tools": {
"github-triage": false,
+10 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -24,7 +22,16 @@ interface PR {
}
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: {
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),
-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 sst/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.
+13 -16
View File
@@ -1,20 +1,10 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -48,9 +38,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
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: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -73,8 +71,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
-8
View File
@@ -1,8 +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.
(Note: rekram1-node is on vacation, do not assign issues to him.)
+15
View File
@@ -20,6 +20,17 @@
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
@@ -111,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+3 -1
View File
@@ -34,7 +34,9 @@
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+3 -1
View File
@@ -34,7 +34,9 @@
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+141
View File
@@ -0,0 +1,141 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Εγκατάσταση
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Διαχειριστές πακέτων
npm i -g opencode-ai@latest # ή bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS και Linux (προτείνεται, πάντα ενημερωμένο)
brew install opencode # macOS και Linux (επίσημος τύπος brew, λιγότερο συχνές ενημερώσεις)
sudo pacman -S opencode # Arch Linux (Σταθερό)
paru -S opencode-bin # Arch Linux (Τελευταία έκδοση από AUR)
mise use -g opencode # Οποιοδήποτε λειτουργικό σύστημα
nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση την πιο πρόσφατη αλλαγή από το dev branch
```
> [!TIP]
> Αφαίρεσε παλαιότερες εκδόσεις από τη 0.1.x πριν από την εγκατάσταση.
### Εφαρμογή Desktop (BETA)
Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download).
| Πλατφόρμα | Λήψη |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, ή AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Κατάλογος Εγκατάστασης
Το script εγκατάστασης τηρεί την ακόλουθη σειρά προτεραιότητας για τη διαδρομή εγκατάστασης:
1. `$OPENCODE_INSTALL_DIR` - Προσαρμοσμένος κατάλογος εγκατάστασης
2. `$XDG_BIN_DIR` - Διαδρομή συμβατή με τις προδιαγραφές XDG Base Directory
3. `$HOME/bin` - Τυπικός κατάλογος εκτελέσιμων αρχείων χρήστη (εάν υπάρχει ή μπορεί να δημιουργηθεί)
4. `$HOME/.opencode/bin` - Προεπιλεγμένη εφεδρική διαδρομή
```bash
# Παραδείγματα
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Πράκτορες
Το OpenCode περιλαμβάνει δύο ενσωματωμένους πράκτορες μεταξύ των οποίων μπορείτε να εναλλάσσεστε με το πλήκτρο `Tab`.
- **build** - Προεπιλεγμένος πράκτορας με πλήρη πρόσβαση για εργασία πάνω σε κώδικα
- **plan** - Πράκτορας μόνο ανάγνωσης για ανάλυση και εξερεύνηση κώδικα
- Αρνείται την επεξεργασία αρχείων από προεπιλογή
- Ζητά άδεια πριν εκτελέσει εντολές bash
- Ιδανικός για εξερεύνηση άγνωστων αρχείων πηγαίου κώδικα ή σχεδιασμό αλλαγών
Περιλαμβάνεται επίσης ένας **general** υποπράκτορας για σύνθετες αναζητήσεις και πολυβηματικές διεργασίες.
Χρησιμοποιείται εσωτερικά και μπορεί να κληθεί χρησιμοποιώντας `@general` στα μηνύματα.
Μάθετε περισσότερα για τους [πράκτορες](https://opencode.ai/docs/agents).
### Οδηγός Χρήσης
Για περισσότερες πληροφορίες σχετικά με τη ρύθμιση του OpenCode, [**πλοηγήσου στον οδηγό χρήσης μας**](https://opencode.ai/docs).
### Συνεισφορά
Εάν ενδιαφέρεσαι να συνεισφέρεις στο OpenCode, διαβάστε τα [οδηγό χρήσης συνεισφοράς](./CONTRIBUTING.md) πριν υποβάλεις ένα pull request.
### Δημιουργία πάνω στο OpenCode
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
### Συχνές Ερωτήσεις
#### Πώς διαφέρει αυτό από το Claude Code;
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
- 100% ανοιχτού κώδικα
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
- Out-of-the-box υποστήριξη LSP
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
---
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+3 -1
View File
@@ -34,7 +34,9 @@
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+4 -1
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+3 -1
View File
@@ -34,7 +34,9 @@
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+141
View File
@@ -0,0 +1,141 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Cài đặt
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Các trình quản lý gói (Package managers)
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
sudo pacman -S opencode # Arch Linux (Bản ổn định)
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
mise use -g opencode # Mọi hệ điều hành
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
```
> [!TIP]
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
### Ứng dụng Desktop (BETA)
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
| Nền tảng | Tải xuống |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, hoặc AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Thư mục cài đặt
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
4. `$HOME/.opencode/bin` - Mặc định dự phòng
```bash
# Ví dụ
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents (Đại diện)
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
- Mặc định từ chối việc chỉnh sửa tệp
- Hỏi quyền trước khi chạy các lệnh bash
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
### Tài liệu
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
### Đóng góp
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
### Xây dựng trên nền tảng OpenCode
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+5 -2
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -134,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+5 -2
View File
@@ -27,13 +27,16 @@
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a>
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -134,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+1541 -488
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"lastModified": 1773909469,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"type": "github"
},
"original": {
+2 -2
View File
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {
@@ -495,7 +496,6 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],
+17 -5
View File
@@ -101,7 +101,13 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
})
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Lite",
name: "OpenCode Go",
})
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
@@ -116,9 +122,10 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
@@ -142,7 +149,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
plan20: zenBlackPrice20.id,
},
})
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
@@ -196,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_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", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -214,10 +224,12 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_BLACK_LIMITS,
ZEN_LITE_PRICE,
ZEN_LITE_LIMITS,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-3hfy6nfEnGq4J6inH0pXANw05oas+81iuayn7J0pj9c=",
"aarch64-linux": "sha256-dxWaLtzSeI5NfHwB6u0K10yxoA0ESz/r+zTEQ3FdKFY=",
"aarch64-darwin": "sha256-kkK4rj4g0j2jJFXVmVH7CJcXlI8Dj/KmL/VC3iE4Z+8=",
"x86_64-darwin": "sha256-jt51irxZd48kb0BItd8InP7lfsELUh0unVYO2es+a98="
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
}
}
+1
View File
@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
../package.json
../patches
../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
]
);
};
+16 -8
View File
@@ -4,11 +4,13 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.9",
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri 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",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -23,7 +25,8 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.9",
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -35,13 +38,14 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.13",
"@pierre/diffs": "1.1.0-beta.18",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -70,12 +74,13 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"turbo": "2.5.6"
"turbo": "2.8.13"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -98,7 +103,8 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter"
"web-tree-sitter",
"electron"
],
"overrides": {
"@types/bun": "catalog:",
@@ -106,6 +112,8 @@
},
"patchedDependencies": {
"@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"
}
}
@@ -0,0 +1,515 @@
# CreateEffect Simplification Implementation Spec
Reduce reactive misuse across `packages/app`.
---
## Context
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
Key issues from the audit:
- Derived state is being written through effects instead of computed directly
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
- User-driven actions are hidden inside reactive effects
- Context layers mirror and hydrate child stores with multiple sync effects
- Several areas repeat the same imperative trigger pattern in multiple effects
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
## Goals
- Cut high-churn `createEffect` usage in the hottest files first
- Replace effect-driven derived state with reactive derivation
- Replace reset-on-key effects with keyed ownership boundaries
- Move event-driven work to direct actions and write paths
- Remove mirrored store hydration where a single source of truth can exist
- Leave necessary external sync effects in place, but make them narrower and clearer
## Non-Goals
- Do not rewrite unrelated component structure just to reduce the count
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
- Do not attempt a repo-wide cleanup outside `packages/app`
## Effect Taxonomy And Replacement Rules
Use these rules during implementation.
### Prefer `createMemo`
Use `createMemo` when the target value is pure derived state from other signals or stores.
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
Apply this to:
- `packages/app/src/pages/session.tsx:141`
- `packages/app/src/pages/layout.tsx:557`
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Rules:
- If no external system is touched, do not use `createEffect`
- Derive once, then read the memo where needed
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
### Prefer Keyed Remounts
Use keyed remounts when local UI state should reset because an identity changed.
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
Apply this to:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
- `packages/app/src/context/file.tsx:100`
Rules:
- If the desired behavior is "new identity, fresh local state," key the owner subtree
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
### Prefer Event Handlers And Actions
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
Apply this to:
- `packages/app/src/pages/layout.tsx:484`
- `packages/app/src/pages/layout.tsx:652`
- `packages/app/src/pages/layout.tsx:776`
- `packages/app/src/pages/layout.tsx:1489`
- `packages/app/src/pages/layout.tsx:1519`
- `packages/app/src/components/file-tree.tsx:328`
- `packages/app/src/pages/session/terminal-panel.tsx:55`
- `packages/app/src/context/global-sync.tsx:148`
- Duplicated trigger sets in:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Rules:
- If the trigger is user intent, call the action at the source of that intent
- If the same imperative work is triggered from multiple places, extract one function and call it directly
### Prefer `onMount` And `onCleanup`
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
Use this when:
- Setup should happen once per owner lifecycle
- Cleanup should always pair with teardown
- The work is not conceptually derived state
### Keep `createEffect` When It Is A Real Bridge
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
Examples that should remain, though they may be narrowed or split:
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
- Scroll sync in `packages/app/src/pages/session.tsx:685`
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- External sync in:
- `packages/app/src/context/language.tsx:207`
- `packages/app/src/context/settings.tsx:110`
- `packages/app/src/context/sdk.tsx:26`
- Polling in:
- `packages/app/src/components/status-popover.tsx:59`
- `packages/app/src/components/dialog-select-server.tsx:273`
Rules:
- Keep the effect single-purpose
- Make dependencies explicit and narrow
- Avoid writing back into the same reactive graph unless absolutely required
## Implementation Plan
### Phase 0: Classification Pass
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
Acceptance criteria:
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
- Shared helpers to be introduced are identified up front to avoid repeating patterns
### Phase 1: Derived-State Cleanup
Tackle highest-value, lowest-risk derived-state cleanup first.
Priority items:
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
- Replace other obvious derived-state effects in terminal and session header
Acceptance criteria:
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
- Targeted derived-state effects are deleted, not just moved
### Phase 2: Keyed Reset Cleanup
Replace reset-on-key effects with keyed ownership boundaries.
Priority items:
- Key session-scoped UI and state by `sessionKey`
- Key file-scoped state by `scope()`
- Remove manual clear-and-reseed effects in session and file context
Acceptance criteria:
- Switching session or file scope recreates the intended local state cleanly
- No stale state leaks across session or scope changes
- Target reset effects are deleted
### Phase 3: Event-Driven Work Extraction
Move event-driven work out of reactive effects.
Priority items:
- Replace `globalStore.reload` effect dispatching with direct calls
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
- Collapse duplicated imperative trigger triplets into single functions
- Move file-tree and terminal-panel imperative work to explicit handlers
Acceptance criteria:
- User-triggered behavior still fires exactly once per intended action
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
### Phase 4: Context Ownership Cleanup
Remove mirrored child-store hydration patterns.
Priority items:
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
Acceptance criteria:
- There is one clear source of truth for each synced value
- Child stores no longer need effect-based hydration to stay consistent
- Initialization and updates both work without manual mirror effects
### Phase 5: Cleanup And Keeper Review
Clean up remaining targeted hotspots and narrow the effects that should stay.
Acceptance criteria:
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
- Mixed-responsibility effects are split into smaller units where still needed
## Detailed Work Items By Area
### 1. Normalize Tab State
Files:
- `packages/app/src/pages/session.tsx:141`
Work:
- Move tab normalization into the functions that create, load, or update tab state
- Make readers consume already-normalized tab data
- Remove the effect that rewrites derived tab state after the fact
Rationale:
- Tabs should become valid when written, not be repaired later
- This removes a feedback loop and makes state easier to trust
Acceptance criteria:
- The effect at `packages/app/src/pages/session.tsx:141` is removed
- Newly created and restored tabs are normalized before they enter local state
- Tab rendering still matches current behavior for valid and edge-case inputs
### 2. Key Session-Owned State
Files:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
Work:
- Identify state that should reset when `sessionKey` changes
- Move that state under a keyed subtree or keyed owner boundary
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
Rationale:
- Session identity already defines the lifetime of this UI state
- Keyed ownership makes reset behavior automatic and easier to reason about
Acceptance criteria:
- The targeted reset effects are removed
- Changing sessions resets only the intended session-local state
- Scroll and editor state that should persist are not accidentally reset
### 3. Derive Workspace Order
Files:
- `packages/app/src/pages/layout.tsx:557`
Work:
- Stop writing `workspaceOrder` from live workspace data in an effect
- Represent user overrides separately from live workspace data
- Compute effective order from current data plus overrides with a memo or pure helper
Rationale:
- Persisted user intent and live source data should not mirror each other through an effect
- A computed effective order avoids drift and racey resync behavior
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
- User overrides persist without requiring a sync-back effect
### 4. Remove Child-Store Mirrors
Files:
- `packages/app/src/context/global-sync.tsx:130`
- `packages/app/src/context/global-sync.tsx:138`
- `packages/app/src/context/global-sync.tsx:148`
- `packages/app/src/context/global-sync/child-store.ts:184`
- `packages/app/src/context/global-sync/child-store.ts:190`
- `packages/app/src/context/global-sync/child-store.ts:193`
- `packages/app/src/context/layout.tsx:424`
Work:
- Trace the actual ownership of global and child store values
- Replace hydration and mirror effects with explicit initialization and direct updates
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
Rationale:
- Mirrors make it hard to tell which state is authoritative
- Event-bus style state toggles hide control flow and create accidental reruns
Acceptance criteria:
- Child store hydration no longer depends on effect-based copying
- Reload work can be followed from the event source to the handler without a reactive relay
- State remains correct on first load, child creation, and subsequent updates
### 5. Key File-Scoped State
Files:
- `packages/app/src/context/file.tsx:100`
Work:
- Move file-scoped local state under a boundary keyed by `scope()`
- Remove any effect that watches `scope()` only to reset file-local state
Rationale:
- File scope changes are identity changes
- Keyed ownership gives a cleaner reset than manual clear logic
Acceptance criteria:
- The effect at `packages/app/src/context/file.tsx:100` is removed
- Switching scopes resets only scope-local state
- No previous-scope data appears after a scope change
### 6. Split Layout Side Effects
Files:
- `packages/app/src/pages/layout.tsx:1489`
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
Work:
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
- Move user-triggered branches into the actual command or handler that causes them
- Remove any branch that only exists because one effect is handling unrelated concerns
Rationale:
- Mixed effects hide cause and make reruns hard to predict
- Smaller units reduce accidental coupling and make future cleanup safer
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
- Event-driven branches execute from direct handlers
- Remaining effects in this area each have one clear external sync purpose
### 7. Remove Duplicate Triggers
Files:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Work:
- Extract one explicit imperative function per behavior
- Call that function from each source event instead of replicating the same effect pattern multiple times
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
Rationale:
- Duplicate triggers make it easy to miss a case or fire twice
- One named action is easier to test and reason about
Acceptance criteria:
- Repeated imperative effect triplets are collapsed into shared functions
- Scroll behavior still works, including hash-based navigation
- No duplicate firing is introduced
### 8. Make Prompt Filtering Reactive
Files:
- `packages/app/src/components/prompt-input.tsx:652`
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
Work:
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
Rationale:
- Filtering is classic derived state
- It should not need an effect if it can be computed from current inputs
Acceptance criteria:
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
- Filtered slash-command results update correctly as the input changes
- The editor sync effect at `:690` still behaves correctly
### 9. Clean Up Smaller Derived-State Cases
Files:
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Work:
- Replace effect-written local state with memos or inline derivation
- Remove intermediate setters when the value can be computed directly
Rationale:
- These are low-risk wins that reinforce the same pattern
- They also help keep follow-up cleanup consistent
Acceptance criteria:
- Targeted effects are removed
- UI output remains unchanged under the same inputs
## Verification And Regression Checks
Run focused checks after each phase, not only at the end.
### Suggested Verification
- Switch between sessions rapidly and confirm local session UI resets only where intended
- Open, close, and reorder tabs and confirm order and normalization remain stable
- Change workspaces, reload workspace data, and verify effective ordering is correct
- Change file scope and confirm stale file state does not bleed across scopes
- Trigger layout actions that previously depended on effects and confirm they still fire once
- Use slash commands in the prompt and verify filtering updates as you type
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
- Verify global sync initialization, reload, and child-store creation paths
### Regression Checks
- No accidental infinite reruns
- No double-firing network or command actions
- No lost cleanup for listeners, timers, or scroll handlers
- No preserved stale state after identity changes
- No removed effect that was actually bridging to DOM or an external API
If available, add or update tests around pure helpers introduced during this cleanup.
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
## Definition Of Done
This work is done when all of the following are true:
- The highest-leverage targets in this spec are implemented
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
- The "should remain" effects still exist only where they serve a real external sync purpose
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
- No behavior regressions are found in the targeted areas
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
The main success metric is clearer ownership and fewer effect-driven state repairs.
## Risks And Rollout Notes
Main risks:
- Keyed remounts can reset too much if state boundaries are drawn too high
- Store mirror removal can break initialization order if ownership is not mapped first
- Moving event work out of effects can accidentally skip triggers that were previously implicit
Rollout notes:
- Land in small phases, with each phase keeping the app behaviorally stable
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
+54 -4
View File
@@ -70,7 +70,15 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
@@ -109,7 +117,7 @@ import { test, expect } from "@playwright/test"
### Error Handling
Tests should clean up after themselves:
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -120,6 +128,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
@@ -156,14 +169,51 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- 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`.
- 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.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- 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
- 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
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- 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
- 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 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
- 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
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. Clean up any created resources
5. Use specific selectors (avoid CSS classes)
6. Test one feature per test file
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development
+495 -55
View File
@@ -1,24 +1,38 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
promptSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
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) {
await page
@@ -29,9 +43,141 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
export async function openPalette(page: Page) {
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(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?.connected && (state.settled ?? 0) > 0
}, 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 }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
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 }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
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 }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
@@ -61,9 +207,49 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
const button = await waitSidebarButton(page, "isSidebarClosed")
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) {
@@ -74,52 +260,39 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const button = await waitSidebarButton(page, "openSidebar")
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(main).not.toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "true")
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const button = await waitSidebarButton(page, "closeSidebar")
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(main).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
}
export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page)
const dialog = page.getByRole("dialog")
@@ -132,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
@@ -193,21 +368,154 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() {
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" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { 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', {
cwd: root,
stdio: "ignore",
})
return root
return resolveDirectory(root)
}
export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
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[] = []) {
let prev = ""
let next = ""
await expect
.poll(
async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
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) {
@@ -216,7 +524,7 @@ export function sessionIDFromUrl(url: string) {
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
@@ -317,6 +625,57 @@ export async function clickListItem(
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
@@ -328,7 +687,7 @@ export async function withSession<T>(
try {
return await callback(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: session.id })
}
}
@@ -441,6 +800,64 @@ export async function seedSessionPermission(
return { id: result.id }
}
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
@@ -512,35 +929,51 @@ export async function openStatusPopover(page: Page) {
}
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()
await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ force: true, timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
await expect(close).toBeVisible()
return menu
}
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
throw new Error(`Failed to open project menu: ${projectSlug}`)
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -553,11 +986,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
if (current === enabled) return
await openProjectMenu(page, projectSlug)
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await toggle.click({ force: true })
const flipped = await flip(1500)
.then(() => true)
.catch(() => false)
if (!flipped) await flip()
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+6 -3
View File
@@ -1,17 +1,20 @@
import { test, expect } from "../fixtures"
import { serverName } from "../utils"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
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: serverName })).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()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()
+10 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
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)
})
+11 -8
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+5 -3
View File
@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})
+10 -3
View File
@@ -43,7 +43,14 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code).toContainText("export default function FileTree")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")
})
+110 -3
View File
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -43,7 +44,113 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible()
await tab.click()
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
+77 -10
View File
@@ -1,6 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors"
import type { E2EWindow } from "../src/testing/terminal"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
@@ -13,6 +23,8 @@ type TestFixtures = {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
@@ -24,6 +36,29 @@ type 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: [
async ({}, use) => {
const directory = await getWorktree()
@@ -45,26 +80,44 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory, sessionID })
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const root = await createTestProject()
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
return await callback({ directory, slug, gotoSession })
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
await cleanupTestProject(directory)
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
},
@@ -73,6 +126,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
+4 -14
View File
@@ -1,25 +1,15 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async () => {
await withProject(async ({ slug }) => {
await openSidebar(page)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
@@ -1,36 +1,8 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -53,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
@@ -5,15 +5,14 @@ import {
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
import { dirSlug, resolveDirectory } from "../utils"
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -51,88 +50,67 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject()
const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug }) => {
rootDir = directory
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
await waitSession(page, { directory: space })
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(`project switch remembers workspace ${stamp}`)
await prompt.press("Enter")
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await rootButton.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})
@@ -1,144 +1,94 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, slug: string) {
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 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)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitDir(page, next.directory)
return next
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
await waitWorkspaceReady(page, space)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const row = page.locator(item(space)).first()
await row.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await openWorkspaceNewSession(page, slug)
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
if (!info) return ""
return info.directory
return sessionID
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root }) => {
const workspaces = [] as { slug: string; directory: string }[]
const sessions = [] as string[]
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
try {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first)
const first = await createWorkspace(page, root, [])
workspaces.push(first)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
const second = await createWorkspace(page, root, [first.slug])
workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), 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)
})
})
+19 -53
View File
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
@@ -13,15 +13,15 @@ import {
confirmDialog,
openSidebar,
openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -29,25 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -59,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
)
.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 }) => {
@@ -91,26 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug)
const next = await resolveSlug(await waitSlug(page, [slug]))
await waitDir(page, next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -122,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
)
.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)
})
})
@@ -142,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("")
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-")
await openSidebar(page)
@@ -279,7 +258,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
@@ -336,9 +315,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
@@ -357,19 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory)
workspaces.push(next)
await openSidebar(page)
}
+35 -2
View File
@@ -1,6 +1,8 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
await cleanupSession({ sdk, sessionID })
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})
@@ -0,0 +1,184 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
// 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 wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await prompt.focus()
await expect(prompt).toBeFocused()
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(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
})
@@ -0,0 +1,62 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})
@@ -0,0 +1,64 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})
@@ -1,4 +1,5 @@
import { test, expect } from "../fixtures"
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -9,15 +10,9 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await waitTerminalFocusIdle(page, { term: terminal })
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await expect(terminal).not.toBeVisible()
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl, withSession } from "../actions"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await sdk.session.delete({ sessionID }).catch(() => undefined)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {
+5 -3
View File
@@ -1,5 +1,6 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
@@ -12,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
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 settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
@@ -30,8 +34,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
@@ -0,0 +1,37 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})
@@ -1,21 +1,37 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
const session = await sdk.session.create({ title }).then((r) => r.data)
async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
return fn(session)
try {
return await fn(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
test.setTimeout(120_000)
@@ -28,6 +44,210 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
await expect(dock).toBeVisible()
await dock.getByRole("button", { name: label }).click()
}
async function setAutoAccept(page: any, enabled: boolean) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try {
return await fn(state)
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
@@ -42,6 +262,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
@@ -62,121 +293,214 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectQuestionOpen(page)
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["REJECT.md"],
})
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow always/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
const dock = await todoDock(page, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
})
})
@@ -196,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
@@ -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 })
})
})
@@ -0,0 +1,426 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, 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.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})
@@ -45,7 +45,7 @@ async function seedConversation(input: {
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBeUndefined()
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions"
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await closeDialog(page, dialog)
const main = page.locator("main")
const initialClasses = (await main.getAttribute("class")) ?? ""
const initiallyClosed = initialClasses.includes("xl:border-l")
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
const finalClasses = (await main.getAttribute("class")) ?? ""
const finalClosed = finalClasses.includes("xl:border-l")
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(finalClosed).toBe(initiallyClosed)
})
@@ -244,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
expect(initialKeybind).toContain("K")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
@@ -305,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).toBeVisible()
await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()

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