Compare commits

...

720 commits

Author SHA1 Message Date
axcho
93d18f26b5 docs: add CACT server deploy playbook
Some checks failed
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
Add an operator runbook for standing up the CloudAxe Coder Team on a new Linux server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 09:34:09 -04:00
axcho
d6fb28bd25 docs: record the 1.18.33 desktop install
Some checks failed
containers / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
publish / version (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 12:41:36 -04:00
axcho
5bb1f3d94c docs: note upstream desktop sync to 1.18.33
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 12:27:41 -04:00
axcho
d2d1e2b685 chore: merge upstream opencode desktop changes 2026-09-29 11:46:28 -04:00
Frank
7945de2089 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-28 18:59:34 -04:00
opencode-agent[bot]
8d05153965 chore: generate 2026-09-28 19:57:36 +00:00
vprdev
d1dc00d83d
feat(console): refresh Go page hero and plans to match design (#51933) 2026-09-28 15:55:38 -04:00
opencode-agent[bot]
90853b2121 chore: generate 2026-09-28 19:53:39 +00:00
vprdev
d03d6e28d6
fix(console): tell referral link visitors the program has ended (#51920) 2026-09-28 15:52:09 -04:00
Frank
09a3aa0b4c zen: jev privacy policy 2026-09-28 14:32:47 -04:00
Adam
7f964bbb00
fix(stats): keep chart tooltips readable (#51898) 2026-09-28 11:30:23 -05:00
opencode-agent[bot]
ad6c72c706 chore: generate 2026-09-28 15:46:01 +00:00
vprdev
75e1e7ae31
fix(console): stop Black subscription renewals (#51884) 2026-09-28 11:44:42 -04:00
Victor Navarro
3c893f0a16
fix(console): polish Go limits chart on mobile (#51870) 2026-09-28 15:39:52 +02:00
Victor Navarro
b76bbb3692
feat(console): compare Go and Go Plus limits side by side (#51865) 2026-09-28 15:25:55 +02:00
vprdev
f416138844
fix(console): fit Go page to mobile screens (#51855) 2026-09-28 09:05:12 -04:00
Frank
083ed266e0 docs(go): simplify usage limit explanation 2026-09-28 07:34:04 -04:00
Adam
acb6859b06
fix(stats): attribute usage from catalog identity (#51833) 2026-09-28 06:08:52 -05:00
opencode-agent[bot]
9f9e73ae0f chore: generate 2026-09-28 11:04:37 +00:00
Frank
c1919a3477 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-28 07:02:20 -04:00
Frank
482a1cdf0d doc: go plus 2026-09-28 06:43:54 -04:00
Jack
725e4ba4b8
feat(go): show Go Plus plans and limits (#51822) 2026-09-28 18:38:26 +08:00
Frank
fed9d78791 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-28 06:11:28 -04:00
Frank
ddbeaa17c0 doc: go plus 2026-09-28 06:07:01 -04:00
Jack
03e67171ab
fix(stats): hide partial-day ranking changes (#51777) 2026-09-28 13:11:35 +08:00
Jack
d6963bdf24
fix(stats): localize weekly ranking option (#51776) 2026-09-28 12:52:16 +08:00
Jack
661b7c5548
feat(stats): add daily model rankings (#51774) 2026-09-28 12:44:38 +08:00
opencode
90e65205f6 sync release versions for v1.18.33 2026-09-28 04:22:44 +00:00
opencode-agent[bot]
1eacc1bdb9
fix: use available model for release changelog (#51773)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-27 23:01:45 -05:00
Dan Lapid
35fc7a776c
fix(opencode): apply provider timeouts to Cloudflare AI Gateway models (#51549) 2026-09-27 22:22:28 -05:00
opencode-agent[bot]
b471c2b449
fix(opencode): catch completed MCP browser launcher failures (#51538)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-26 14:56:38 -05:00
Tommy D. Rossi
a42f393c85
docs(web): link kimaki to product website (#49735) 2026-09-26 10:55:17 -05:00
Jack
b65de4d694
docs(go): add LongCat 2.5 Preview Free to V1 pages (#51488) 2026-09-26 21:16:22 +08:00
opencode-agent[bot]
696f41bc8e chore: update nix node_modules hashes 2026-09-25 23:27:56 +00:00
Aiden Cline
29f07e0c69
refactor(core): share a browser opener across opencode and tui (#51414) 2026-09-25 18:09:47 -05:00
opencode-agent[bot]
adee738d1e chore: generate 2026-09-25 16:14:58 +00:00
Jack
543c686c0d
fix(go): make DeepSeek $60 allowance permanent (#51364) 2026-09-26 00:13:42 +08:00
Jack
34aa427434
docs(zen): document Qwen3.8 Max (#51321) 2026-09-25 19:00:58 +08:00
opencode-agent[bot]
16c56fe5ec
fix(ci): use GPT-6 Luna for issue automation (#51261)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-24 22:13:00 -05:00
Adam
6df0d5d951
fix(stats): use capability fallbacks in comparison radar (#51161) 2026-09-24 14:26:39 -05:00
Jack
0f549842ee
fix(stats): attribute Hy4 preview traffic to Tencent (#51050)
Co-authored-by: MrMushrooooom <19261047+MrMushrooooom@users.noreply.github.com>
2026-09-24 14:20:31 +08:00
Jack
0027387dc5
fix(go): include DeepSeek V4 Flash in chart (#51032) 2026-09-24 12:32:44 +08:00
opencode-agent[bot]
5fcfb06f94 chore: update nix node_modules hashes 2026-09-24 02:18:19 +00:00
Vladimir Glafirov
79e8eee5cf
chore: bump gitlab-ai-provider to 6.18.0 (#50890) 2026-09-23 21:03:37 -05:00
opencode-agent[bot]
82d4c89031
fix(opencode): redact credentials in debug config (#50956)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-23 17:35:46 -05:00
Jack
1d6c3c0e29
feat(go): add GPT 6 Luna to Go pages (#50947) 2026-09-24 02:25:48 +08:00
Mark McDonald
610df0b566
fix(gemini): switch thinking default logic (#50841)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-23 11:41:37 -05:00
opencode-agent[bot]
7cb044ee89 chore: generate 2026-09-23 13:59:42 +00:00
Jack
1b4a6dbc65
feat(go): add Space Bunny promotion (#50572) 2026-09-23 21:58:18 +08:00
opencode-agent[bot]
18ef3cc7c5 chore: update nix node_modules hashes 2026-09-23 04:38:22 +00:00
Vladimir Glafirov
45719acebb
maint(gitlab): bump gitlab-ai-provider to 6.16.0 (#50742) 2026-09-22 23:21:33 -05:00
opencode-agent[bot]
2406400f0a chore: generate 2026-09-22 19:25:10 +00:00
vprdev
fe51b0b19a
fix(console): restrict legacy access to Black (#50716) 2026-09-22 12:23:33 -07:00
Daniel Chen
6ea95e5e6f
docs(web): add Claude Opus 5.5, GPT 6 Sol, and GPT 6 Luna to Zen (#50708) 2026-09-22 11:54:04 -07:00
opencode-agent[bot]
fc827ea8d5 chore: generate 2026-09-22 18:38:53 +00:00
opencode-agent[bot]
3a35b45db8
feat(openai): allow GPT-6 Sol and Luna with Codex (#50704)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-22 13:36:43 -05:00
opencode
fe3f3a41f7 sync release versions for v1.18.32 2026-09-21 22:51:18 +00:00
Aiden Cline
f5ce4f881e
fix(core): break filesystem search import cycle (#50439)
Co-authored-by: frank <48937772+joe-ieta@users.noreply.github.com>
Co-authored-by: kernel-oops <300539221+kernel-oops@users.noreply.github.com>
2026-09-21 16:41:28 -05:00
opencode-agent[bot]
f54ce313b9 chore: generate 2026-09-21 19:56:28 +00:00
Jack
32808ad5f1
docs: add MiMo V2.6 models (#50395) 2026-09-22 03:55:06 +08:00
Aiden Cline
ba341c6cac
fix(core): resolve npm package entrypoint to a file under Node (#50413) 2026-09-21 14:36:52 -05:00
Daniel Chen
e059ac5918
feat: add Grok 4.7 to Zen and Go (#50288) 2026-09-21 08:32:28 -07:00
Frank
70a24697ea fix(console): authenticate inference proxy 2026-09-21 04:40:11 -04:00
Frank
cf494c2029 fix(console): forward client IP to inference 2026-09-21 04:02:27 -04:00
Aiden Cline
c10134729d
fix(opencode): hoist Bedrock tool images except Claude, Nova, and Llama 4 (#50272) 2026-09-20 22:31:41 -05:00
opencode-agent[bot]
0e3dfd1769 chore: update nix node_modules hashes 2026-09-21 02:15:03 +00:00
Aiden Cline
8bf288ecb1
fix(opencode): bump togetherai so streams report usage (#50264) 2026-09-20 21:01:45 -05:00
Jack
17b7678875
docs(web): extend DeepSeek promotion (#50255) 2026-09-21 08:40:48 +08:00
Adam
d870e22c70
fix(stats): retry transient query failures (#50205) 2026-09-20 17:43:04 +00:00
Adam
45ad8dc38a
fix(stats): paginate sync aggregates (#50198) 2026-09-20 17:00:07 +00:00
opencode-agent[bot]
8b2de7317b chore: generate 2026-09-20 16:23:45 +00:00
Aiden Cline
ac88182b3e
ci: sync v2 catalog once a day (#50194) 2026-09-20 11:22:28 -05:00
Frank
ebb7b76eca docs(web): align Jev section heading 2026-09-19 20:49:02 -04:00
Frank
11be5bc29c docs(web): document Jev usage 2026-09-19 20:41:21 -04:00
Aiden Cline
83abc64a5c
chore(ci): refresh v2 models.dev snapshot on a schedule (#50030) 2026-09-19 16:46:33 -05:00
opencode-agent[bot]
fee476bb90
refactor(console): redirect desktop downloads (#49984)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-19 14:31:47 +00:00
opencode-agent[bot]
ae93d4afb3
fix(console): use v2 desktop downloads (#49931)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 09:01:25 +00:00
opencode-agent[bot]
9a53565790 chore: generate 2026-09-19 08:05:40 +00:00
Frank
61eae8811a docs(web): add Jev models to Zen 2026-09-19 04:04:16 -04:00
Frank
34b4c3cd9f fix(console): update systemone endpoints 2026-09-19 03:37:25 -04:00
Dax Raad
3c7f191972 fix(web): add v2 banner border and restore header hairline 2026-09-19 03:35:42 -04:00
Dax Raad
7c22dbbaf9 fix(web): use neutral colors for v2 banner 2026-09-19 03:29:50 -04:00
Dax Raad
6da6d47aab fix(web): render v2 banner full width below header 2026-09-19 03:29:17 -04:00
Dax Raad
f1aacaba22 feat(web): add v2 announcement banner to legacy docs 2026-09-19 03:14:51 -04:00
opencode-agent[bot]
4e1c49630a chore: generate 2026-09-19 04:17:02 +00:00
Daniel Chen
a6cad7743f
docs: add DeepSeek V4.1 Flash to Zen (#49897) 2026-09-18 21:15:46 -07:00
Jack
ae2da69004
docs: add Qwen3.8 Flash to Zen (#49888) 2026-09-19 11:30:51 +08:00
Dax
285cff53da
feat(console): switch install links to v2 (#49874) 2026-09-19 01:27:27 +00:00
opencode-agent[bot]
5f9d9187c0 chore: generate 2026-09-18 15:59:01 +00:00
Frank
1573a7b608 support jev 2026-09-18 11:57:10 -04:00
Jack
3dd1b30539
docs: restore GPT-5.6 Sol pricing (#49724) 2026-09-18 18:06:35 +08:00
opencode-agent[bot]
e80db11160 chore: generate 2026-09-18 09:14:23 +00:00
Jack
a7f2373a05
chore(go): retire Union Alpha (#49718) 2026-09-18 17:13:11 +08:00
opencode-agent[bot]
b02acc1e30
chore(stats): update GitHub star fallback (#49654)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 20:50:22 -05:00
opencode-agent[bot]
5a8335857b
fix(console): show migrated workspaces in selector (#49520)
Co-authored-by: vimtor <vimtor@users.noreply.github.com>
2026-09-17 11:25:14 +02:00
Adam
88c6c7abc7
fix(stats): preserve container dependency lock (#49385) 2026-09-16 13:15:53 -05:00
opencode-agent[bot]
a6183af6f3
fix(stats): hide Union Alpha provider (#49356)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 12:47:12 -05:00
opencode-agent[bot]
2e018f70f2
fix(stats): guard oversized model names (#49363)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 16:47:21 +00:00
Jack
1364769e51
fix(go): label Union Alpha as free (#49353) 2026-09-16 23:30:58 +08:00
opencode-agent[bot]
350c726aa8 chore: generate 2026-09-16 13:44:33 +00:00
Jack
ee91f190c5
feat(go): add Union Alpha (#49178) 2026-09-16 21:42:09 +08:00
Victor Navarro
501ff62cbf
fix(console): exclude migrated workspaces from selection (#49336) 2026-09-16 15:07:22 +02:00
Frank
e03db9bc69 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-14 17:41:36 -04:00
Frank
403627b031 deepseek v4.1 flash 2026-09-14 17:41:34 -04:00
vprdev
5cf4e13588
fix(console): route new Console keys to inference (#49036) 2026-09-14 14:32:56 -07:00
opencode-agent[bot]
4ae176520d
chore: add Alan to team members (#49054)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-14 14:16:46 -07:00
opencode
a74c472ffb sync release versions for v1.18.31 2026-09-14 17:47:27 +00:00
opencode-agent[bot]
a97622c801
fix(tui): surface remote auth startup errors (#49016)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-14 13:24:08 -04:00
opencode-agent[bot]
7f209437b5
test(app): retire legacy layout e2e coverage (#49015)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-14 12:08:40 -05:00
Matthew Feroz
228e9095ba
fix(ui): add Merge Gateway logo (#42015) 2026-09-14 02:16:48 -05:00
Frank
df23b7f948 feat(console): forward Go billing source 2026-09-13 17:38:53 -04:00
opencode-agent[bot]
631f67a9f3 chore: update nix node_modules hashes 2026-09-13 17:14:41 +00:00
Rohan Taneja
199a4cdbea
chore(deps): bump @ai-sdk/gateway to 3.0.191 (#48710)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-13 11:56:55 -05:00
opencode-agent[bot]
a453386e9d chore: generate 2026-09-13 15:24:43 +00:00
Jack
ca34cecc03
docs(go): show DeepSeek V4.1 Flash promo limits (#48824) 2026-09-13 23:23:29 +08:00
Jacob Wolf
95daf90670
fix(acp): restore session options and reasoning boundaries (#48225)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-11 18:46:41 +05:30
opencode-agent[bot]
0b934e9516 chore: generate 2026-09-11 13:07:53 +00:00
黑墨水鱼
5de2f241c6
feat(console): add batch workspace block endpoints (#48491) 2026-09-11 21:06:26 +08:00
Adam
193de13a88
fix(stats): normalize deepseek v4.1 flash 2026-09-10 15:33:35 -05:00
Adam
43e89ea167
fix(stats): prevent full retention sync timeouts 2026-09-10 14:50:04 -05:00
Jack
b7ca4f91d9
docs(go): update DeepSeek V4.1 Flash model ID (#48363) 2026-09-11 01:53:15 +08:00
Victor Navarro
b3f1a96c6d
fix(console): unblock Go subscriptions in dev (#48309) 2026-09-10 11:25:59 +02:00
Jack
859106eb17
docs(go): remove Omen Alpha listings (#48280) 2026-09-10 12:22:43 +08:00
opencode-agent[bot]
486e8460b1 chore: generate 2026-09-10 03:06:55 +00:00
Jack
28a62b7148
feat(go): add DeepSeek V4.1 Flash (#48270) 2026-09-10 11:05:41 +08:00
Aiden Cline
a9a6fad0fa
fix(opencode): request summarized adaptive thinking (#48269)
Co-authored-by: Aljosha Friemann <1730315+afriemann@users.noreply.github.com>
2026-09-09 21:51:42 -05:00
opencode-agent[bot]
b6914b39db chore: generate 2026-09-09 22:45:26 +00:00
Jack
72635a3720
feat(console): clarify Go model usage (#48192)
Co-authored-by: Frank <frank@anoma.ly>
2026-09-09 18:44:00 -04:00
Victor Navarro
9f8db119fc
feat(console): route migrated Go inference (#48123) 2026-09-09 19:45:38 +02:00
Jack
f69beceaff
docs(go): update GLM-5.3-Flash allowance (#48130) 2026-09-09 23:41:56 +08:00
opencode
830d5eb535 sync release versions for v1.18.30 2026-09-09 03:34:25 +00:00
Aiden Cline
5cd8e68fdd
feat(opencode): port Astra system prompt from v2 (#48057) 2026-09-08 21:27:13 -05:00
opencode-agent[bot]
dff8fbc149 chore: generate 2026-09-08 16:21:49 +00:00
Emrick
ac1758c0e6
fix: preserve Bedrock DeepSeek model ids (#34441)
Co-authored-by: yeqisong <yeqisong@authing.com>
2026-09-08 11:20:22 -05:00
opencode-agent[bot]
d6855b6b47 chore: generate 2026-09-08 06:51:49 +00:00
Jack
eebd85f5d1
docs(go): translate client compatibility guidance (#47901) 2026-09-08 14:50:35 +08:00
opencode-agent[bot]
ecbc6ccac8 chore: update nix node_modules hashes 2026-09-07 16:21:14 +00:00
Vladimir Glafirov
0b082b065d
chore: bump gitlab-ai-provider to 6.15.0 (#47792) 2026-09-07 11:03:30 -05:00
Aiden Cline
57ef382843
feat(console): publish oauth client metadata document (#47737) 2026-09-07 01:11:00 -05:00
Dax Raad
f914cac3d4 docs 2026-09-07 01:49:16 -04:00
opencode-agent[bot]
53fec37d8d chore: generate 2026-09-07 04:03:53 +00:00
Dax Raad
a03bba5f2c docs 2026-09-07 00:02:27 -04:00
opencode-agent[bot]
56d4fb24a1 chore: generate 2026-09-07 03:44:38 +00:00
Dax Raad
13e3744f76 docs(go): document client session compatibility 2026-09-06 23:42:25 -04:00
opencode-agent[bot]
e207624c48 chore: update nix node_modules hashes 2026-09-06 22:05:22 +00:00
opencode-agent[bot]
ea2d59d7ca
fix(provider): preserve explicit OpenAI service tiers (#47671)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 16:48:43 -05:00
opencode-agent[bot]
bec9ee41af
fix(provider): bump Azure SDK to 3.0.93 (#47664)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 16:34:09 -05:00
opencode-agent[bot]
c470c79513 chore: update nix node_modules hashes 2026-09-06 20:59:14 +00:00
opencode-agent[bot]
23ec4f55c8
fix(provider): bump OpenAI SDK to 3.0.88 (#47659)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: GuestAUser <62957566+GuestAUser@users.noreply.github.com>
2026-09-06 15:40:58 -05:00
opencode-agent[bot]
337fd144d2 chore: generate 2026-09-06 04:32:46 +00:00
Jack
1ddaeec480
docs(console): add localized refund FAQs for Go and Zen (#47575) 2026-09-06 12:31:27 +08:00
far-ouq
7c2199d84a
fix(opencode): add GitLab reasoning variants (#47306) 2026-09-05 18:29:29 -05:00
Stefan Avram
bbd72fb8b0
fix(console): connect enterprise form to Chatwoot (#47488)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 15:51:32 +02:00
黑墨水鱼
e2894562f8
fix(console): openai usage normalization and tier threshold config (#47342)
Co-authored-by: Jack <jack@anoma.ly>
2026-09-05 03:36:32 +00:00
Frank
70b4ca8c18 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-09-04 21:49:05 -04:00
Frank
4db1eec321 update zen docs 2026-09-04 21:48:55 -04:00
opencode-agent[bot]
79903a4cf7 chore: generate 2026-09-05 01:40:11 +00:00
Frank
e8548ae8b2 update zen models 2026-09-04 21:38:36 -04:00
Frank
7774580e61 zen: add muse spark 2026-09-04 21:32:15 -04:00
opencode
5b1e31988e sync release versions for v1.18.29 2026-09-04 23:47:13 +00:00
Aiden Cline
02a167e048
fix(opencode): compare Codex GPT versions by major and minor (#47385) 2026-09-04 18:22:56 -05:00
Aiden Cline
500c46ec79
fix(opencode): allow integer GPT versions in Codex model filter (#47384) 2026-09-04 18:14:21 -05:00
黑墨水鱼
5cf9f517cf
feat(console): add quota reset support action (#47313) 2026-09-05 02:47:06 +08:00
Peter267
5d5c35ee71
docs(zh): fix bold rendering by adding spaces around asterisks (#46231) 2026-09-04 12:50:14 -05:00
opencode
51f86c8537 sync release versions for v1.18.28 2026-09-04 15:38:19 +00:00
Jack
20a7743876
docs(go): renew DeepSeek ZDR through September 2026 (#47288) 2026-09-04 21:31:04 +08:00
opencode-agent[bot]
31afdd5938 chore: generate 2026-09-04 12:34:53 +00:00
Adam
4178fd74f1
fix(stats): hide stealth model providers 2026-09-04 07:33:04 -05:00
Victor Navarro
3f31139064
feat(console): route migrated BYOK through provider connections (#47266) 2026-09-04 12:42:58 +02:00
Jack
475b408119
fix(console): backport usage reset boundary fix to dev (#47267) 2026-09-04 18:41:12 +08:00
opencode-agent[bot]
70f74112e3
fix(stats): keep omen-alpha under unknown provider (#47248)
Co-authored-by: fwang <83515+fwang@users.noreply.github.com>
2026-09-04 04:22:48 -04:00
opencode-agent[bot]
03cb632435
test(core): disable npm audits in the test preload (#47222)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-04 00:34:37 -05:00
opencode-agent[bot]
dd417f1a6b chore: generate 2026-09-04 05:09:10 +00:00
Jack
8b9f89e7e8
docs(go): add Omen Alpha (#47220) 2026-09-04 13:07:43 +08:00
opencode-agent[bot]
c0f09afef5
feat(copilot): send X-Interaction-Id header with session id (#47215)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 23:24:55 -05:00
Adam
8a6cf2c9aa
feat(stats): improve market share chart (#47115) 2026-09-03 16:03:32 -05:00
opencode-agent[bot]
a935432b5c chore: generate 2026-09-03 19:58:32 +00:00
David Hill
7561b4a050
fix(tui): use unicode ellipses in interface text (#45126) 2026-09-03 13:56:40 -06:00
opencode-agent[bot]
d8eb3b80fb chore: generate 2026-09-03 17:42:08 +00:00
Adam
08c483dc36
fix(stats): reduce database query load 2026-09-03 12:38:24 -05:00
Victor Navarro
d2efd81fb3
fix(console): proxy migrated model discovery to v1 endpoint (#47065) 2026-09-03 17:04:58 +02:00
Jack
79d503150c
docs(web): translate Go usage requirements (#47057) 2026-09-03 21:52:04 +08:00
opencode-agent[bot]
f12e14cf16
fix(app): identify desktop in Console device auth (v1) (#47000) 2026-09-03 11:01:59 +02:00
David Hill
b578b7261f
fix(app): increase open-in icon size (#46540) 2026-09-03 09:47:21 +08:00
Victor M. SMITH
bbe4c952d7
docs: add Eden AI to the providers list (#43386) 2026-09-02 20:35:18 -05:00
opencode-agent[bot]
3a9d4e78b6 chore: update nix node_modules hashes 2026-09-02 23:33:47 +00:00
Vladimir Glafirov
8d1f8916d3
chore: bump gitlab-ai-provider to 6.13.0 (#46914) 2026-09-02 18:18:04 -05:00
opencode
05028334b2 sync release versions for v1.18.27 2026-09-02 21:40:57 +00:00
Aiden Cline
b04697366f
fix(opencode): default header timeout to five minutes (#46903) 2026-09-02 16:00:54 -05:00
Aiden Cline
4eb29a64f0
fix(opencode): default chunk timeout to five minutes (#46890) 2026-09-02 15:33:15 -05:00
Darien Kindlund
68abdce1a0
fix(opencode): let config opt out of Anthropic thinking blockBinding (#46820)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-02 14:15:39 -05:00
Victor Navarro
ef2792511d
fix(console): restore migrated inference proxy requests (#46854) 2026-09-02 18:44:43 +02:00
Aiden Cline
9a71624d2d
fix(provider): scope thinking binding to Claude 5.1+ (#46848) 2026-09-02 11:30:28 -05:00
opencode-agent[bot]
ffbdee7b17 chore: generate 2026-09-02 16:06:24 +00:00
Jack
77aec36da1
feat(console): add Muse Spark 1.3 and Gemini 3.8 Flash (#46836) 2026-09-03 00:04:37 +08:00
Victor Navarro
50efc055de
feat(console): keep migrated teams on the new Console (#46830) 2026-09-02 17:52:15 +02:00
Alex
69c172e8a7
fix(provider): handle SSE reader cancel rejections (#44944) 2026-09-01 23:28:47 -05:00
Jack
82b665075b
docs(zen): add Claude Fable 5.1 (#46728) 2026-09-02 11:54:40 +08:00
opencode
8e0f1c253b sync release versions for v1.18.26 2026-09-01 21:52:12 +00:00
opencode-agent[bot]
8ff796f133 chore: update nix node_modules hashes 2026-09-01 21:18:22 +00:00
Aiden Cline
8100c68b50
fix(app): bump happy-dom to fix GC-dependent MutationObserver flake (#46675) 2026-09-01 16:00:24 -05:00
Aiden Cline
86387e90b2
test(opencode): fix session tools test typecheck and runtime (#46677) 2026-09-01 15:24:30 -05:00
opencode-agent[bot]
c10d6767c9 chore: update nix node_modules hashes 2026-09-01 20:18:42 +00:00
Roscoe A. Bartlett
765ae641d7
fix(core): Fix for incorrect time.start reset in tool call logging (#32574) (#32596) 2026-09-01 15:17:03 -05:00
Aiden Cline
c066339d80
test(opencode): guard patched dependency versions (#46673) 2026-09-01 14:59:45 -05:00
Aiden Cline
1542195217
fix(opencode): allow none reasoning effort in Bedrock SDK (#46671) 2026-09-01 14:57:55 -05:00
Filip
af1f9e6269
remove azure discovery stuff (#46666) 2026-09-01 21:53:43 +02:00
opencode-agent[bot]
2961956c74 chore: update nix node_modules hashes 2026-09-01 19:53:04 +00:00
Aiden Cline
3f39a329c3
feat(opencode): tolerate Anthropic thinking block binding (#46653) 2026-09-01 14:32:26 -05:00
opencode-agent[bot]
dc8753f82a chore: update nix node_modules hashes 2026-09-01 19:11:11 +00:00
KevinZhou
4502ee568e
fix(core): bump @ai-sdk/amazon-bedrock to 4.0.166 for reasoning and replay fixes (#45520)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-01 13:52:35 -05:00
Aiden Cline
55c54d14b8
chore: use native runtime conditions in development (#46644) 2026-09-01 13:50:55 -05:00
Aiden Cline
2da5a4b034
refactor(tui): use OpenTUI Dynamic in session view (#46649) 2026-09-01 13:47:49 -05:00
Aiden Cline
216ba8f05f
fix(opencode): stop Azure model discovery from logging to stdout (#46646) 2026-09-01 13:39:45 -05:00
opencode-agent[bot]
df6aecdbc5 chore: generate 2026-09-01 17:14:51 +00:00
Adam
1ce281b7ab
fix(stats): prevent comparison legend collapse 2026-09-01 12:12:22 -05:00
Victor Navarro
5341a5e442
feat(console): add workspace migration timestamp (#46627) 2026-09-01 18:20:52 +02:00
Jack
ebece6efd7
docs(web): update Qwen3.7 Max Go usage (#46555) 2026-09-01 15:11:17 +08:00
Dax Raad
1ead9e3d7f fix(web): number Go usage requirements 2026-08-31 22:45:35 -04:00
Dax Raad
be3b703a7b fix(web): restore documentation list markers 2026-08-31 22:45:35 -04:00
Kyle Altendorf
f7da00f35e
fix(opencode): omit empty apply patch move path (#45329) 2026-08-31 21:37:19 -05:00
opencode-agent[bot]
5c5c709fee
fix(tui): pin diff highlights query (#46519)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:16:05 -05:00
opencode-agent[bot]
2386fcec75 chore: generate 2026-09-01 01:35:29 +00:00
Dax Raad
ba790579ea docs on proper usage of OpenCode Go 2026-08-31 21:34:02 -04:00
opencode-agent[bot]
04284921ac chore: generate 2026-08-31 17:33:52 +00:00
Adam
b639de07ac
fix(stats): merge deepseek flash variants (#46446) 2026-08-31 12:32:22 -05:00
opencode-agent[bot]
26ff3ed3d3
fix(tui): keep home shortcuts right-aligned (#36906)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-31 12:01:31 -04:00
opencode-agent[bot]
9f69463f1d
fix(app): backport session rename and tab menu fixes to v1 (#46116)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 05:19:51 +00:00
Jack
10765ff2a9
fix: remove Hy3 Free docs and correct Go chart rendering (#46221) 2026-08-30 12:09:56 +08:00
Jack
be53e17e19
docs(go): end Hy3 usage promotion (#46213) 2026-08-30 11:34:46 +08:00
opencode-agent[bot]
dc4449df0d chore: update nix node_modules hashes 2026-08-29 02:34:49 +00:00
Kit Langton
62a2f0e617
feat(console): animate Go usage allowances and bonuses (#46055) 2026-08-29 02:22:28 +00:00
Jack
df35e842f5
docs(zen): add Ling 3.0 Flash Fin Free (#45923) 2026-08-28 20:04:58 +08:00
Jack
1be9fd55a9
docs(go): add Hy4 preview (#45904) 2026-08-28 18:21:28 +08:00
opencode
755ebdb94e sync release versions for v1.18.25 2026-08-28 05:58:17 +00:00
opencode-agent[bot]
c2e39bb556
test(opencode): use native config path in permission assertion (#45849)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-28 05:52:06 +00:00
opencode-agent[bot]
733562e92a
fix(opencode): remove Bun dependency from Azure authentication (#45845)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-28 15:38:25 +10:00
Jack
8a7cc0c0ff
docs(go): add Qwen3.8 Flash (#45836) 2026-08-28 13:17:02 +08:00
opencode
19db518e0a sync release versions for v1.18.24 2026-08-28 04:10:10 +00:00
opencode-agent[bot]
15537a41d2
fix(opencode): compare config snapshots as JSON (#45784)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-27 20:08:12 -04:00
opencode-agent[bot]
790fb5b86f
feat(opencode): support Azure CLI authentication (#45079)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 01:50:15 +02:00
opencode-agent[bot]
517ee736b3
fix(provider): filter unreplayable Bedrock reasoning before caching (#45769)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-28 00:48:48 +02:00
opencode-agent[bot]
c77100a40c chore: generate 2026-08-27 20:05:43 +00:00
James Long
03afae5b95
feat(opencode): load supported v2 config in v1 (#45421) 2026-08-27 16:03:36 -04:00
opencode-agent[bot]
05ea5073be
fix(console): improve Go comparison chart on mobile (#45044)
Co-authored-by: jayair <53023+jayair@users.noreply.github.com>
2026-08-27 13:21:00 -04:00
opencode-agent[bot]
5f5ea53afb chore: generate 2026-08-27 11:13:37 +00:00
Adam
1120d0704e
fix(stats): map ox alpha to glm 5.3 flash (#45542) 2026-08-27 06:12:21 -05:00
opencode-agent[bot]
6568a82455
fix(console): merge duplicate Go usage rows (#45503)
Co-authored-by: MrMushrooooom <19261047+MrMushrooooom@users.noreply.github.com>
2026-08-27 03:11:56 -04:00
Adam
c2eacd72af
fix(console): secure server action redirects (#45374) 2026-08-26 15:03:16 -05:00
Adam
c5ef753d28
fix(stats): align retention columns 2026-08-26 11:14:49 -05:00
Adam
530535c6ea
fix(stats): reduce retention query scan 2026-08-26 10:38:12 -05:00
Adam
023620b57e
chore: add sst unlock workflow 2026-08-26 09:39:34 -05:00
Adam
902e67eba9
feat(stats): add weekly retention 2026-08-26 09:02:23 -05:00
Jack
830aaf2059
docs(go): add GLM-5.3-Flash (#45269) 2026-08-26 21:49:06 +08:00
Adam
a0f36c9df7
feat(stats): add retention metrics 2026-08-26 07:39:44 -05:00
opencode-agent[bot]
1216c55094 chore: generate 2026-08-26 11:20:31 +00:00
Jack
ec25388937
docs: remove Ox Alpha Free (#45221) 2026-08-26 19:19:12 +08:00
opencode-agent[bot]
c7134cbb01 chore: update nix node_modules hashes 2026-08-26 10:55:43 +00:00
Adam
ba4d0ea8bf
fix(console): validate auth redirects (#45027) 2026-08-26 05:41:23 -05:00
opencode-agent[bot]
1cc53890dc chore: update nix node_modules hashes 2026-08-26 07:47:41 +00:00
Frank
ae2ea3c723 fix map inaccuracy 2026-08-26 03:40:35 -04:00
Frank
3f31551fad fix map inaccuracy 2026-08-26 03:31:48 -04:00
Frank
13c27598d3 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-26 02:31:35 -04:00
Frank
2564a4f172 remove map 2026-08-26 02:31:32 -04:00
Ravitez Dondeti
fd9bd448a2
docs: mention Exa and Parallel as web search backends (#38395) 2026-08-25 19:57:17 -05:00
axcho
9a4830ef4a docs: note desktop rebuild and reinstall verification
Some checks failed
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:03:45 -04:00
opencode-agent[bot]
b72b50006b
fix(core): recover legacy database migration history (#45061)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 21:58:35 +02:00
axcho
c7e53a1958 fix(app): make Add Server form fields editable and persist credentials
Some checks are pending
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:27:14 -04:00
axcho
74372f6840 docs: finalize upstream desktop sync changelog
Some checks are pending
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:15:55 -04:00
axcho
995b823ee4 chore: use catalog drizzle-orm in lockfile
Some checks failed
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
Align desktop drizzle-orm lock entry with package catalog after merge install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:15:14 -04:00
axcho
851e958191 fix(desktop): adapt voice settings to upstream ExternalLink
Upstream renamed Link to ExternalLink; refresh lockfile after merge install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 13:31:41 -04:00
Jack
ac1c048e64
docs(go): add Grok 4.6 (#45042) 2026-08-26 01:27:48 +08:00
axcho
fc2c29bd3a chore: merge upstream opencode desktop changes
Merge origin/dev into CloudAxe fork while preserving voice-control commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 12:54:13 -04:00
Dax
8615731d46
fix(console): rate limit checkout session creation (#45007) 2026-08-25 11:25:59 -04:00
OpeOginni
a7444bf944
fix(ui): restore focus in stacked dialogs (#44928)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 19:05:49 +08:00
Frank
69aaa22793 zen: void invoice of cancelled subscription 2026-08-25 05:26:31 -04:00
opencode-agent[bot]
322e2b9dd5 chore: generate 2026-08-25 09:07:56 +00:00
Frank
afd6f3b064 zen: display quota usage breakdown 2026-08-25 05:06:33 -04:00
Frank
1e86be2bc5 update inference headers 2026-08-25 04:22:20 -04:00
Nathan Thomassin
a57230b80b
fix(app): drop archived sessions from home list right away (#44905)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 07:34:24 +00:00
Frank
6bb1a76e58 update inference headers 2026-08-25 03:30:20 -04:00
Frank
bdcb6be649 update inference headers 2026-08-25 03:14:21 -04:00
opencode
2f36ffe35d sync release versions for v1.18.23 2026-08-25 06:30:46 +00:00
Frank
31c409a865 update inference headers 2026-08-25 01:54:38 -04:00
Dax Raad
d0ceaef6aa docs(console): prohibit abusive multi-account use 2026-08-24 23:32:44 -04:00
Charlie Gleason
3ef72fe8f6
fix(provider): route non-native Cloudflare AI Gateway providers via the REST API (#44828)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 22:01:17 -05:00
opencode-agent[bot]
51070b6f59 docs: clarify prompt data handling (#44854)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Dax <mail@thdxr.com>
2026-08-24 22:31:47 -04:00
Filip
18b4cb6819
docs(github): correct action token configuration (#44793) 2026-08-25 00:29:56 +02:00
Frank
0561bac189 add client header replacement 2026-08-24 17:40:33 -04:00
Filip
f4019cab3e
fix(github): support immutable OIDC subjects (#44776) 2026-08-24 23:23:22 +02:00
Charlie Gleason
f8b4dd70ac
fix(provider): send Anthropic's dashed native slug through the AI Gateway (#44281)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:15:26 -05:00
opencode-agent[bot]
611cc73d84
fix(opencode): send parent session header (#44752)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 15:24:47 -05:00
Frank
7cde8329bc update model parser 2026-08-24 13:19:53 -04:00
Frank
be15db5861 Revert "delay removing first month discount"
This reverts commit 754bb7e390.
2026-08-24 12:07:27 -04:00
opencode
55f984126c sync release versions for v1.18.22 2026-08-24 14:37:14 +00:00
opencode-agent[bot]
2a6be0a03b chore: generate 2026-08-24 12:50:53 +00:00
Dax
2a36236132
fix(opencode): normalize upgrade endpoint (#44686) 2026-08-24 12:49:33 +00:00
opencode-agent[bot]
105b398c2a
docs(acp): update Zed custom agent config (#44658)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-24 16:22:48 +05:30
opencode-agent[bot]
9fa27bd41c chore: generate 2026-08-24 10:04:12 +00:00
Jack
6bb772215b
docs(go): add LongCat-2.0 (#44636) 2026-08-24 18:02:51 +08:00
Jack
03521003fa
docs(go): clarify DeepSeek weekend pricing (#44637) 2026-08-24 16:21:37 +08:00
Frank
754bb7e390 delay removing first month discount 2026-08-24 04:00:55 -04:00
Frank
4371298fff
fix(console): discontinue first-month Go discount (#44633) 2026-08-24 03:54:38 -04:00
opencode-agent[bot]
3f2e0e89ec chore: generate 2026-08-24 07:18:00 +00:00
Frank
4dbeeddc77 discontinue first month discount 2026-08-24 03:01:02 -04:00
Frank
4161695885 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-24 02:19:03 -04:00
Frank
7bbfe425f6 always allow ox alpha in go 2026-08-24 02:18:34 -04:00
Frank
f2a1d547f1 fix(console): set duplex for streamed zen requests 2026-08-24 02:13:33 -04:00
opencode-agent[bot]
b1ce938ebe
fix(console): support DeepSeek weekend pricing (#44305)
Co-authored-by: MrMushrooooom <MrMushrooooom@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-08-24 01:54:31 -04:00
opencode-agent[bot]
03bba464d4 chore: generate 2026-08-23 17:15:36 +00:00
Dax
63a883a4f7
refactor(console): stream zen bodies without modifiers (#44472) 2026-08-23 13:14:18 -04:00
Dax
b3bad6b581
fix(console): revert scanned zen request streaming (#44470) 2026-08-23 13:03:20 -04:00
opencode-agent[bot]
ca10088bdf chore: generate 2026-08-23 16:51:36 +00:00
Dax
dd3f915956
fix(console): release streamed zen prefixes (#44465) 2026-08-23 12:50:18 -04:00
opencode-agent[bot]
c11c41bd86 chore: generate 2026-08-23 16:48:45 +00:00
Dax
fa117558ee
fix(console): scan zen model before streaming (#44463) 2026-08-23 12:47:29 -04:00
Frank
17b47301ba feat(console): add unblock workspace action 2026-08-23 12:46:02 -04:00
Dax
3d31e4bcec
fix(console): revert streamed zen request bodies (#44444) 2026-08-23 11:54:07 -04:00
opencode-agent[bot]
bb72277407 chore: generate 2026-08-23 15:19:54 +00:00
Dax
32c3637da0
fix(console): stream zen request bodies (#44429) 2026-08-23 11:18:38 -04:00
Dax Raad
9d466cd849 fix(stats): normalize model casing 2026-08-23 10:33:11 -04:00
opencode-agent[bot]
e3bd6e0947 chore: generate 2026-08-23 14:00:45 +00:00
Dax
dc13c6bb3d
fix(console): reduce zen request memory (#44403) 2026-08-23 09:59:29 -04:00
Brendan Allan
3a31c4ea80
fix(app): keep model provider headers visible (#44115) 2026-08-22 20:50:02 +08:00
Kit Langton
e00890c672
fix: resolve console device URLs (#44029) 2026-08-22 00:25:56 +00:00
joelstucki-taulia
3a4c253969
fix(provider): guard textVerbosity injection for @ai-sdk/openai-compatible providers (#43915)
Co-authored-by: Joel Stucki <joel.stucki@example.com>
2026-08-21 22:19:33 +00:00
Adam
34a83b27c3
fix(stats): improve chart tooltip truncation 2026-08-21 17:13:13 -05:00
opencode-agent[bot]
ff3ef6e3e6 chore: update nix node_modules hashes 2026-08-21 21:54:44 +00:00
opencode-agent[bot]
e2ec62d073
fix: bump Amazon Bedrock provider (#43909)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-21 23:39:17 +02:00
opencode-agent[bot]
bcf1103a8c
docs(zen): update GPT 5.6 Sol pricing (#43986)
Co-authored-by: Slickstef11 <98915060+Slickstef11@users.noreply.github.com>
2026-08-21 17:00:03 -04:00
Dax Raad
9cb6fb6529 fix(console): cache desktop downloads 2026-08-21 16:58:52 -04:00
opencode-agent[bot]
1b937c860b
test(opencode): align unknown finish coverage (#43895)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-21 10:56:50 -04:00
opencode
ad0bb6d9a3 sync release versions for v1.18.21 2026-08-21 14:51:08 +00:00
opencode-agent[bot]
487d584402
docs(zen): reflect GPT 5.6 Sol discount (#43874) 2026-08-21 22:49:25 +08:00
opencode-agent[bot]
57fa34f235
fix(opencode): continue unknown finish responses (#43892)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-21 10:30:15 -04:00
Adam
0af9dd61d2
fix(stats): merge renamed model data (#43883) 2026-08-21 08:38:31 -05:00
opencode-agent[bot]
8ecd4c21bf chore: generate 2026-08-21 13:24:26 +00:00
opencode-agent[bot]
361a71ffad
fix(opencode): route Vertex multi-regions through REP (#42648)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-21 15:23:05 +02:00
opencode-agent[bot]
fa8f17014c chore: generate 2026-08-21 12:58:43 +00:00
Jack
813e6f3cec
docs(go): add DeepSeek vision model (#43866) 2026-08-21 20:57:25 +08:00
Jack
ba72a6ff2b
fix(go): align Ox Alpha model ID (#43837) 2026-08-21 17:03:23 +08:00
Brendan Allan
b6a1f95858
fix(app): preserve file search results while loading (#43836) 2026-08-21 08:52:45 +00:00
Nathan Thomassin
f357e70779
fix(app): register archive session command in both layouts (#41741) 2026-08-21 08:28:40 +00:00
opencode
a9cac91d60 sync release versions for v1.18.20 2026-08-21 08:09:28 +00:00
Aiden Cline
35fe5b7212
fix(opencode): surface subagent tool errors (#43821) 2026-08-21 02:44:44 -05:00
opencode-agent[bot]
62cb3f77bd
test(opencode): remove flaky subagent test (#43819)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 02:35:25 -05:00
opencode-agent[bot]
e0b9e68a68
fix(opencode): retry raw network finish errors (#43813)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 02:17:35 -05:00
opencode-agent[bot]
1e4c153aed chore: generate 2026-08-21 07:07:33 +00:00
Jack
c9dc1a1b3d
feat(go): add Ox Alpha to usage graph (#43809) 2026-08-21 15:06:23 +08:00
opencode-agent[bot]
40282c1d4d
fix(opencode): retry network error variants (#43806)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 01:47:20 -05:00
opencode-agent[bot]
e11dbd0206 chore: generate 2026-08-21 06:25:22 +00:00
Jack
7b11ef5a5e
docs(go): add Ox Alpha Free (#43798) 2026-08-21 14:24:06 +08:00
opencode-agent[bot]
2859603cbb chore: generate 2026-08-21 02:58:48 +00:00
opencode-agent[bot]
c313504c82
fix(opencode): surface resumable subagent errors (#43657)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 21:57:33 -05:00
opencode-agent[bot]
e49772a8b4
fix(opencode): preserve Cerebras completion limit (#43736)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: ryanl-cerebras <230249923+ryanl-cerebras@users.noreply.github.com>
2026-08-20 19:30:30 -05:00
opencode-agent[bot]
08faeb3893
fix(opencode): answer subagent permissions in run (#43675)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 19:29:32 -05:00
Frank
5e75e5e990 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-20 14:53:36 -04:00
Frank
5cc5b622f0 update muse country list 2026-08-20 14:53:34 -04:00
Jack
11e8110f9e
fix(docs): align Ox Alpha free model ID (#43695) 2026-08-21 01:49:11 +08:00
Jack
82dee24302
docs(zen): add Ox Alpha free model (#43690) 2026-08-21 01:31:56 +08:00
Adam
ad192a59b5
fix(stats): clarify market share providers (#43647) 2026-08-20 10:46:24 -05:00
opencode-agent[bot]
71d08e94d5
fix(opencode): retry xAI capacity stream errors (#43640)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-20 09:29:30 -05:00
opencode
b155b15694 sync release versions for v1.18.19 2026-08-20 06:22:03 +00:00
Frank
d06bf487a5 sync 2026-08-20 02:19:05 -04:00
Frank
ccccd75328 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-20 02:08:32 -04:00
Frank
03d65f36d3 sync 2026-08-20 02:08:22 -04:00
Aiden Cline
f4a89683da
fix(tui): remove encrypted label from thought header (#43588) 2026-08-20 00:46:39 -05:00
Frank
f715478a13 doc muse regions 2026-08-20 01:43:59 -04:00
opencode-agent[bot]
e0e9bd7d5f
fix(tui): show encrypted reasoning status (#43578)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-20 00:23:20 -05:00
opencode-agent[bot]
5ccb1e88e1
fix(stats): normalize routed model ids (#43582)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-20 01:12:18 -04:00
Frank
be469f328c sync 2026-08-20 00:42:47 -04:00
opencode-agent[bot]
fc80874f45 chore: generate 2026-08-20 02:49:11 +00:00
Frank
3d26b12251 zen: muse spark contributor 2026-08-19 22:47:41 -04:00
Frank
1b77242d40 country check for muse spark in free 2026-08-19 22:44:35 -04:00
Adam
4c5960dc4b
feat(stats): include free tier usage 2026-08-19 19:39:23 -05:00
Frank
e2505d434a Revert "update go models"
This reverts commit 16af134053.
2026-08-19 15:03:41 -04:00
Frank
a466a357b6 Revert "chore: generate"
This reverts commit 6386e67949.
2026-08-19 15:03:39 -04:00
opencode-agent[bot]
6386e67949 chore: generate 2026-08-19 17:11:33 +00:00
Frank
16af134053 update go models 2026-08-19 13:09:39 -04:00
Jack
d545d8fba5
feat(go): promote Hy3 usage (#43429) 2026-08-19 21:36:12 +08:00
opencode-agent[bot]
da4730e4a4 chore: generate 2026-08-19 06:36:52 +00:00
Frank
18fc3eea7c update zen model name 2026-08-19 02:35:19 -04:00
opencode-agent[bot]
101ff6d1a2 chore: generate 2026-08-19 04:53:24 +00:00
Jack
20ad497bc8
docs(console): end Luna usage promotion (#43351) 2026-08-19 12:52:09 +08:00
opencode-agent[bot]
1b18a50418 chore: generate 2026-08-19 03:58:10 +00:00
Frank
0a6397272c Merge branch 'muse-spark' into dev 2026-08-18 23:56:22 -04:00
Frank
3477d28a87 Merge branch 'muse-spark' of github.com:anomalyco/opencode into muse-spark 2026-08-18 23:55:07 -04:00
Frank
fb772e8a02 sync 2026-08-18 23:54:57 -04:00
Frank
4e29e04f24 sync 2026-08-18 23:52:44 -04:00
Frank
fbcd4faa61 sync 2026-08-18 23:48:49 -04:00
Frank
da3ba63872 sync 2026-08-18 23:42:23 -04:00
Frank
e2a2bc301d sync 2026-08-18 23:13:41 -04:00
Frank
72824fe65d sync 2026-08-18 23:11:12 -04:00
Frank
64b4f7df4c sync 2026-08-18 22:55:47 -04:00
bhuvankakkar
7774461bbf
docs: add SCX.ai to the providers list (#42520) 2026-08-18 21:11:56 -05:00
opencode-agent[bot]
8b65fa2ef6
fix(opencode): remove Qwen sampling defaults (#43310)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-18 17:59:59 -05:00
Filip
0033bb3559
fix(core): restore session request headers (#43188) 2026-08-18 21:27:28 +02:00
OpeOginni
ad905f8e6c
fix(opencode): properly show authed providers on /connect command (#39915) 2026-08-18 18:48:25 +00:00
Shoubhit Dash
9b0dd36cda
fix(session): ignore malformed model costs (#43248) 2026-08-18 19:49:08 +05:30
Adam
4e81a0b73f
fix(console): preserve inference sessions (#43124)
Co-authored-by: Frank <frank@anoma.ly>
2026-08-18 00:48:25 -04:00
opencode-agent[bot]
32320409b1
fix(app): keep server details editable (#43169)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 03:38:06 +00:00
Dax Raad
040b856140 fix(cli): stop legacy preview publishing 2026-08-17 23:08:34 -04:00
opencode-agent[bot]
65c35977bd chore: update nix node_modules hashes 2026-08-17 19:25:35 +00:00
opencode-agent[bot]
57075d8cdc
fix(provider): update Google Vertex SDK (#43108)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 14:11:23 -05:00
Frank
e14acea581 update ds flash limit 2026-08-17 14:54:03 -04:00
Filip
7af274a921
fix(core): fall back on oversized websocket requests (#43099) 2026-08-17 20:36:00 +02:00
Filip
a97fec8af5
fix: codex data residency (#42432) 2026-08-17 19:03:28 +02:00
opencode-agent[bot]
2cba7e227d
fix(cli): update default console URL (#43043)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-08-17 13:31:33 +02:00
Game On
4d68d30b48
tweak: match codex limits for openai models exactly when using chatgpt subscription (#39082) 2026-08-17 00:39:52 -05:00
opencode-agent[bot]
5a0e07efcf chore: generate 2026-08-17 04:54:34 +00:00
Aiden Cline
cba6b5f2f7
feat(opencode): native OpenAI and Anthropic passthroughs for Cloudflare AI Gateway (#42634)
Co-authored-by: Keefe Tang <keefe@pentagram.me>
2026-08-16 23:53:20 -05:00
opencode-agent[bot]
1c965451b5
fix(stats): correct YouTube footer link (#42941)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-16 23:58:45 +02:00
Jack
a0f8dccbfe
docs: update DeepSeek V4 pricing (#42881) 2026-08-17 00:01:15 +08:00
Dax Raad
fb8344f3c2 chore: remove scheduled beta sync 2026-08-16 11:26:41 -04:00
Frank
3fd77ae980 zen: peak pricing 2026-08-16 05:20:04 -04:00
Jack
976c185172
docs(go): remove DeepSeek Flash promotion (#42858) 2026-08-16 14:08:47 +08:00
opencode-agent[bot]
4643e65ad6
fix(opencode): enable web search for Go (#42630)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-14 13:23:26 -05:00
Jack
e23586af26
feat(go): add GLM 5.3 (#42518) 2026-08-14 13:48:32 +08:00
opencode-agent[bot]
92d29ba4a3 chore: generate 2026-08-14 04:17:00 +00:00
Jack
886fd98f52
docs(zen): add Muse Spark 1.2 (#42508) 2026-08-14 12:15:43 +08:00
opencode-agent[bot]
722e717e99 chore: update nix node_modules hashes 2026-08-14 03:25:42 +00:00
Aiden Cline
6d635007ab
chore(deps): update ai-gateway-provider to 3.2.0 (#42488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 22:08:45 -05:00
opencode-agent[bot]
0e3474509a
docs: sort Gemini 3.7 before 3.6 (#42473)
Co-authored-by: Stefan Avram <98915060+Slickstef11@users.noreply.github.com>
2026-08-14 10:24:22 +08:00
Dax
d8bf79225f
fix(opencode): preserve v1 database compatibility (#42444) 2026-08-14 00:05:15 +00:00
opencode-agent[bot]
8a55ba75b5 chore: generate 2026-08-13 18:57:31 +00:00
Jack
2449581543
fix(go): remove Gemini 3.7 Flash (#42393) 2026-08-14 02:55:24 +08:00
opencode-agent[bot]
3e25e80f7a chore: generate 2026-08-13 18:44:28 +00:00
Jack
f06e9491e1
feat(go): add Gemini 3.7 Flash (#42390) 2026-08-14 02:41:12 +08:00
Jack
d0c2b41adf
docs(go): use responses API for Grok 4.5 (#42373) 2026-08-14 01:28:55 +08:00
Frank
c7af47f9ed update grok endpoint 2026-08-13 13:27:41 -04:00
Kit Langton
6c035e1fd7
fix(core): preserve unicode in grep previews (#42356) 2026-08-13 16:51:43 +00:00
opencode-agent[bot]
ab7cbc808f chore: generate 2026-08-13 16:30:12 +00:00
Aditya Sethi
62387f39d4
fix(skills): Update global config path in documentation (#42337) 2026-08-13 18:27:29 +02:00
Jack
864889ab9f
docs: remove Ling 3.0 Tiny free model (#42314) 2026-08-13 20:48:54 +08:00
opencode
cc4b456129 sync release versions for v1.18.18 2026-08-13 01:15:01 +00:00
opencode-agent[bot]
14b37df391 chore: update nix node_modules hashes 2026-08-12 22:36:40 +00:00
opencode-agent[bot]
91df883231
fix(opencode): select Kimi prompt by provider (#42161)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:29:03 -05:00
opencode-agent[bot]
6fea419feb
fix(groq): pass through reasoning effort (#42166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:20:23 -05:00
opencode-agent[bot]
beeabe2e4b
fix(mistral): pass through reasoning effort (#42164)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:20:07 -05:00
opencode-agent[bot]
502310f4df
fix(xai): pass through reasoning effort (#42160)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 17:19:17 -05:00
opencode
37fe5c83dc sync release versions for v1.18.17 2026-08-12 20:25:04 +00:00
Aiden Cline
dab2637217
fix(compaction): adjust instructions and structure to be more clear to smaller models like dsv4 flash (#42045)
Co-authored-by: akenra <37288280+akenra@users.noreply.github.com>
2026-08-12 14:36:51 -05:00
opencode-agent[bot]
39fb919a05
chore: add neriousy to team members (#42107)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 13:30:38 -05:00
opencode-agent[bot]
999be62662 chore: generate 2026-08-12 16:56:04 +00:00
opencode-agent[bot]
521906f5fa
docs(go): clarify DeepSeek ZDR coverage (#42085)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-12 12:53:58 -04:00
Frank
069165eb5b Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-12 12:48:10 -04:00
Frank
df09c3ec61 update ds v4 pro 2026-08-12 12:48:08 -04:00
opencode-agent[bot]
6d3ae4d63d chore: generate 2026-08-12 16:47:08 +00:00
Adam
284187ac55
fix(ci): authenticate pulumi downloads 2026-08-12 11:45:00 -05:00
Adam
7e0353cca9
fix(stats): correct r2 daily totals 2026-08-12 11:36:23 -05:00
SKY ZHAO
959c8bd498
docs: fix provider display name and PAT typos (#42034)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:32:23 -05:00
SKY ZHAO
ca3df21b7f
docs: fix broken DigitalOcean and Daytona links (#42048)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:31:47 -05:00
Matthew Feroz
8571a922db
fix(provider): add Merge Gateway reasoning variants (#41867) 2026-08-12 10:31:19 -05:00
Frank
d92d1e654b docs(zen): add Grok 4.6 2026-08-12 10:53:38 -04:00
Adam
46a14e685a
feat(stats): query r2 data catalog 2026-08-12 09:49:10 -05:00
Jack
1f94d8a3c8
docs(zen): remove expired free models (#41943) 2026-08-12 12:03:45 +08:00
Aiden Cline
c78986831c
fix(opencode): cap session retries with jitter (#41939) 2026-08-11 22:50:52 -05:00
vimtor
d470434746
refactor(console): simplify go usage response 2026-08-11 23:18:57 +02:00
Steven Ao
561afb401a
fix(opencode): detect Copilot PDF input support (#41522) 2026-08-11 16:04:36 -05:00
Arif Rahman (Bolt)
2b8a5969e9
feat(console): add go usage endpoint (#16513)
Co-authored-by: vimtor <vn4varro@gmail.com>
2026-08-11 22:25:23 +02:00
Jack
36b205370d
docs(zen): add Hy3 Free (#41814) 2026-08-12 02:42:06 +08:00
opencode-agent[bot]
6afef2f881
fix(console): update blocked account message (#41819)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
2026-08-11 14:41:37 -04:00
opencode-agent[bot]
0d927ba03f chore: generate 2026-08-11 14:17:42 +00:00
Jack
9fdd4824d3
docs(zen): add Nemotron 3.5 Lightning (#41750) 2026-08-11 22:15:53 +08:00
opencode-agent[bot]
d041eee55c
chore: use DeepSeek for beta conflicts (#41669)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 04:58:15 +00:00
Luke Parker
d62e50075a
chore: build beta from v2 (#41627) 2026-08-11 14:33:42 +10:00
opencode-agent[bot]
5d953482ab
fix(provider): scope DeepSeek V4 Flash sampling defaults (#41620)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: 侯秀凯 <9627034+hoooou@users.noreply.github.com>
2026-08-10 23:01:56 -05:00
OpeOginni
3a90639cb5
fix(ui): correct OC-2 weak icon color (#41504) 2026-08-10 17:08:45 -05:00
Matthias Reso
b9f3b382fc
fix(session): route all Muse family models to the Meta system prompt (#41581) 2026-08-10 13:28:04 -05:00
opencode-agent[bot]
550d1ffd24
fix(i18n): use widely recognized developer terminology (#41532)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-10 22:00:00 +10:00
Brendan Allan
941e71dbbb
fix(app): use current default model (#38603) 2026-08-10 15:14:17 +08:00
opencode
d90532a595 sync release versions for v1.18.16 2026-08-10 06:07:05 +00:00
opencode-agent[bot]
0bff28de09
fix(stats): fall back after full sync failure (#41411) 2026-08-09 10:58:58 -05:00
opencode-agent[bot]
38e10eb140
fix(opencode): ignore unknown config fields (#41312) 2026-08-08 14:53:43 -04:00
opencode-agent[bot]
fe82a1b6ca chore: generate 2026-08-08 03:39:08 +00:00
Brendan Allan
2ea728f731
fix(app): populate project picker from home (#41158) 2026-08-08 11:37:30 +08:00
Aarav Sareen
284214c78d
feat(app): open project menu on right-click (#41013) 2026-08-07 17:25:46 +08:00
日常无语的无语者
b05a0d1fbe
fix(i18n): use 词元 instead of 令牌 for token in zh locale (#40977) 2026-08-07 19:22:23 +10:00
Jack
be25c905fa
docs(zen): replace Ling Flash with Tiny (#41027) 2026-08-07 16:48:42 +08:00
Brendan Allan
5347b5e009
fix(app): register new home projects (#41018) 2026-08-07 07:34:32 +00:00
opencode-agent[bot]
b467518dae
fix(desktop): preserve macOS app on window close (#40974)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-07 07:25:12 +00:00
opencode
1ec6bdc8c6 sync release versions for v1.18.15 2026-08-07 06:49:53 +00:00
Aarav Sareen
265472ef72
fix(ui): use tokens for button loading state (#40884) 2026-08-07 12:11:00 +05:30
Frank
325529761b sync 2026-08-07 02:22:25 -04:00
opencode-agent[bot]
741244b69d
feat(app): add global locale coverage (#40992)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 05:41:11 +00:00
Jack
4abddbbfdd
feat(go): promote DeepSeek V4 Flash usage (#40988) 2026-08-07 13:24:03 +08:00
opencode-agent[bot]
5aa5cb3523
fix(app): use chronological message boundaries (#41006)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:23:28 +00:00
opencode-agent[bot]
9113255114
fix(app): order stored messages by creation time (#41001)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:06:28 +00:00
opencode-agent[bot]
23cc677108
fix(tui): order messages by creation time (#40994)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:41:15 +00:00
opencode-agent[bot]
28bcc0e4f4
fix(app): sort sessions by persisted time (#41000)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:23:04 +00:00
Dmitry Nefedov
8bf5062b89
feat(tui): add cursor style configuration (#32295)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 23:20:51 -05:00
opencode-agent[bot]
20750c332e
fix(web): order shared messages by creation time (#40995)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:11:28 +00:00
opencode-agent[bot]
db581e47a3
fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:23 +00:00
opencode-agent[bot]
a54a693af2
fix(opencode): use chronological message boundaries (#40991)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:02 +00:00
opencode-agent[bot]
d468201952
fix(opencode): use file times for truncation cleanup (#40987)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 03:58:31 +00:00
opencode-agent[bot]
31c5c4e924
fix(app): keep locale names native (#40985)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 03:32:43 +00:00
opencode-agent[bot]
8d65dbdd04
fix(app): complete translation coverage (#40981)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-07 03:06:23 +00:00
opencode-agent[bot]
b7f9363393
fix(opencode): serialize orphaned compaction history (#40800)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 21:11:14 -05:00
opencode-agent[bot]
69f2cbaa3a chore: generate 2026-08-06 23:37:25 +00:00
Frank
dc898ca2c3 sync 2026-08-06 19:34:26 -04:00
Adam
03bff6500a
fix(app): update homepage stats 2026-08-06 11:07:33 -05:00
Adam
b379d71d19
fix(stats): update github stars 2026-08-06 10:58:58 -05:00
ayu
def7220bfc
fix(tui): support copying over ssh with set-clipboard on tmux config (#30472)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-06 10:23:32 +02:00
opencode-agent[bot]
f6c5afe5a9
fix(desktop): disable packaged console logging (#40794)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-06 17:25:41 +10:00
Frank
847771fe06 fix log processor 2026-08-06 03:21:16 -04:00
opencode-agent[bot]
b8bd88901a chore: generate 2026-08-06 01:52:15 +00:00
Luke Parker
f1adabcddc
feat(app): export session as json from ui (#40781) 2026-08-06 11:50:33 +10:00
Luke Parker
24470e52a5
fix(desktop): embed version in server sidecar (#40764) 2026-08-06 07:22:11 +08:00
mridul
23bbc5cd14
fix(server): allow blob attachments in web UI (#40692) 2026-08-05 22:23:32 +00:00
opencode
ebf6fc07a1 sync release versions for v1.18.14 2026-08-05 20:58:52 +00:00
opencode-agent[bot]
04513d9692 chore: generate 2026-08-05 20:56:57 +00:00
Adam
146720e197
fix(stats): improve page speed 2026-08-05 15:54:35 -05:00
opencode-agent[bot]
b1f8cc04af chore: update nix node_modules hashes 2026-08-05 20:33:25 +00:00
opencode-agent[bot]
082fe93e16 chore: generate 2026-08-05 20:16:40 +00:00
Aiden Cline
709c195905
fix(opencode): preserve compatible stream errors (#40718) 2026-08-05 15:14:03 -05:00
opencode-agent[bot]
3355b78d91 chore: generate 2026-08-05 20:06:05 +00:00
Adam
b84c63d034
fix(stats): reduce html payloads 2026-08-05 15:02:39 -05:00
opencode-agent[bot]
057b5a9dee chore: generate 2026-08-05 19:41:32 +00:00
Aiden Cline
61aefc0759
fix(opencode): expand retryable error patterns (#40707) 2026-08-05 14:39:49 -05:00
Aiden Cline
f929f8f100
refactor(opencode): simplify retry error matching (#40694) 2026-08-05 13:05:19 -05:00
opencode-agent[bot]
4a57013cf8
fix(app): show pending tool details (#40603)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 08:25:06 +00:00
Jack
2f17fc9613
docs(zen): add LongCat free model (#40585) 2026-08-05 12:49:28 +08:00
opencode-agent[bot]
b8ea3ea091 chore: generate 2026-08-05 02:33:08 +00:00
Luke Parker
82a5796159
test(app): harden flaky e2e synchronization (#40556) 2026-08-05 12:31:45 +10:00
opencode-agent[bot]
9f38562237
fix(opencode): include cache writes in ACP usage (#40450)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 21:27:01 -05:00
opencode-agent[bot]
5b4fb1f770 chore: generate 2026-08-05 02:20:04 +00:00
Aiden Cline
cb88db6ce3
tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (#40537) 2026-08-04 21:18:44 -05:00
Luke Parker
66fdd51f0d
docs: add RTL development skill (#40543) 2026-08-05 01:01:10 +00:00
opencode-agent[bot]
98dd65cd60
fix(app): collapse deletion-only edit parts (#40536)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 00:23:06 +00:00
James Murdza
f0afb6750e
fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James Murdza
703d09f306
fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
opencode
aefaf140c1 sync release versions for v1.18.13 2026-08-04 12:12:30 +00:00
Shoubhit Dash
44614c79c4
fix(acp): drain updates before end turn (#40422) 2026-08-04 17:26:33 +05:30
Amy Duquette
dc67b7a3be
docs: add Poolside provider setup (#39187) 2026-08-04 06:43:54 -05:00
Luke Parker
de1bd36a47
fix(app): correct RTL layout interactions (#40410) 2026-08-04 11:39:32 +00:00
opencode-agent[bot]
6c97a46dae
fix(stats): hide zero-usage countries (#40400) 2026-08-04 06:26:48 -05:00
Luke Parker
c387fe190b
feat(app): add debug layout direction toggle (#40393) 2026-08-04 08:52:37 +00:00
opencode-agent[bot]
8721a2710b
fix(app): complete shared RTL primitives (#40388)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 07:38:56 +00:00
Brendan Allan
c75e3b5bae
fix(app): supply fallback session titles (#40385) 2026-08-04 15:09:29 +08:00
opencode-agent[bot]
edc8d1d91e chore: generate 2026-08-04 06:57:27 +00:00
opencode-agent[bot]
e85735028c
feat(i18n): localize hardcoded application copy (#40377)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 16:56:03 +10:00
opencode-agent[bot]
6c3299103c
feat(app): add minimal RTL support (#40372)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 05:08:51 +00:00
opencode-agent[bot]
60e23fee16 chore: generate 2026-08-04 04:38:17 +00:00
opencode-agent[bot]
0c380f31ac
feat(app): support locale plural rules (#40370)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 04:36:57 +00:00
opencode-agent[bot]
26aefa68f5 chore: update nix node_modules hashes 2026-08-04 04:30:14 +00:00
Luke Parker
638788f8d0
fix(app): move markdown parsing to worker (#40356) 2026-08-04 04:13:34 +00:00
opencode-agent[bot]
b005a15a4a chore: generate 2026-08-04 03:58:49 +00:00
opencode-agent[bot]
0efa33cf33
feat(desktop): expand and audit locale translations (#40362)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 03:57:33 +00:00
opencode-agent[bot]
7e94ddafe8
fix(github): include pull request identity in context (#40363)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-03 22:18:52 -05:00
opencode
7fe993879f sync release versions for v1.18.12 2026-08-04 00:55:26 +00:00
Frank
def14d96ef Revert "fix slow queries" 2026-08-03 20:14:17 -04:00
opencode-agent[bot]
b0b1149233
test(opencode): cover Azure completion reasoning (#40340)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 09:50:17 +10:00
Aarav Sareen
7becbbf711
feat(app): refine diff viewer (#40285)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-03 22:49:49 +00:00
Frederik
87481f2bb6
fix: gpt-5.5+ in combination with azure fails with reasoningEffort (#40265) 2026-08-03 17:42:30 -05:00
opencode-agent[bot]
9535a8f929
fix(ui): update OpenRouter logo (#40313)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 07:08:09 +10:00
YB
e917c12c80
docs: remove obsolete vouch system section (#40266) 2026-08-03 14:17:27 -05:00
opencode-agent[bot]
89130db6b0 chore: update nix node_modules hashes 2026-08-03 10:07:49 +00:00
Frank
9465a6c97d Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-03 05:54:55 -04:00
Frank
53fd02970d fix slow queries 2026-08-03 05:54:41 -04:00
Luke Parker
6ff0adef22
perf(app): fix composer lag via buffered blob draft storage (#40207) 2026-08-03 19:49:08 +10:00
opencode-agent[bot]
3307f99f1f
fix(ci): reuse imported macOS keychain (#40221)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-03 19:48:32 +10:00
Frank
8de7a3c739 fix slow queries 2026-08-03 05:41:46 -04:00
opencode-agent[bot]
28d0637406 chore: generate 2026-08-03 08:48:38 +00:00
Frank
ef7f712867 fix slow queries 2026-08-03 04:47:10 -04:00
Joshua
3016830e25
fix(app): search every known project in the open project dialog (#40202) 2026-08-03 14:54:00 +08:00
Jack
e9e7472456
docs(go): add Qwen3.8 Max (#40228) 2026-08-03 14:48:57 +08:00
Brendan Allan
9f9fbf815c
fix(app): clear stale timeline errors (#39465) 2026-08-03 14:18:52 +08:00
opencode-agent[bot]
c88facb2cc chore: generate 2026-08-03 06:09:39 +00:00
twinkle
d4b85f8d83
fix(app): skip legacy config reads for v2 (#40211) 2026-08-03 14:08:22 +08:00
Frank
1882c33827 fix gemini reasoning tokens not counted 2026-08-02 02:31:20 -04:00
opencode-agent[bot]
01624c8c87 chore: generate 2026-08-02 03:36:50 +00:00
Jack
0891ecd8e0
docs(go): update DeepSeek privacy policy (#40120) 2026-08-02 11:35:35 +08:00
Frank
32f278b48f Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-08-01 10:25:21 -04:00
Frank
bb6bec9070 zen: update doc 2026-08-01 10:25:18 -04:00
opencode
0eac0964af sync release versions for v1.18.11 2026-08-01 11:44:43 +00:00
OpeOginni
f67e80c275
fix(app): prevent stale prompt control reads (#39842) 2026-08-01 09:46:46 +00:00
opencode-agent[bot]
19231fce4b chore: generate 2026-07-31 17:18:52 +00:00
Frank
d0a7ca6d1b go: document data policy per model 2026-07-31 13:16:18 -04:00
Frank
d80e86be9d go: document data policy per model 2026-07-31 13:15:27 -04:00
opencode-agent[bot]
e4bd9757a3 chore: generate 2026-07-31 12:18:55 +00:00
Jack
4087cf1a97
fix(go): restore limits graph axis (#39870) 2026-07-31 20:17:28 +08:00
opencode-agent[bot]
14f0bf64a1 chore: generate 2026-07-31 08:31:55 +00:00
Frank
2df47ee394 deepseek v4 flash 2026-07-31 04:28:41 -04:00
opencode-agent[bot]
ad82616445 chore: update nix node_modules hashes 2026-07-31 08:24:41 +00:00
opencode-agent[bot]
d4ad650f73 chore: generate 2026-07-31 08:03:42 +00:00
Luke Parker
2039c90c06
fix(desktop): open external links in system browser (#39820) 2026-07-31 18:02:11 +10:00
Jack
da59457ca4
feat(go): add GPT 5.6 Luna content (#39812) 2026-07-31 01:23:25 -04:00
OpeOginni
db4dbaa289
fix(app): prevent stale session tab reads (#39767) 2026-07-31 05:14:09 +00:00
opencode-agent[bot]
d3f30df180
fix(app): open legacy picker at home (#39804)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-31 04:38:34 +00:00
OpeOginni
7ea343cabd
fix(app): prevent file tree tab clipping (#39770) 2026-07-31 12:24:19 +08:00
Luke Parker
e024e2ef92
fix(app): align debug gutter text (#39782) 2026-07-31 01:46:36 +00:00
axcho
9f5a50a369 docs(desktop): note CloudAxe voice fork publication
Some checks failed
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-07-30 16:47:11 -04:00
axcho
78bf9a35eb feat(desktop): add voice control
Some checks failed
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
2026-07-30 16:20:14 -04:00
Deven Navani
ceb4890ca3
docs: add Modal provider setup (#39710) 2026-07-30 13:29:32 -05:00
opencode-agent[bot]
3f239b3834 chore: update nix node_modules hashes 2026-07-30 16:46:38 +00:00
Aiden Cline
c1ee3c6e34
fix(opencode): stop MCP SSE error reconnect loops (#39697) 2026-07-30 11:26:48 -05:00
opencode-agent[bot]
a1ab489e61
fix(provider): widen interleaved reasoning fields (#39556)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:10:34 -05:00
opencode
8c38d260eb sync release versions for v1.18.10 2026-07-30 14:43:50 +00:00
opencode-agent[bot]
a4f25a94b4
fix: use OpenCode model catalog URL (#39672)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-30 10:12:08 -04:00
opencode-agent[bot]
ff0382e971 chore: generate 2026-07-30 09:31:27 +00:00
OpeOginni
ac5380614f
fix(app): repair malformed persisted tabs (#39645) 2026-07-30 17:29:40 +08:00
opencode-agent[bot]
c08350b83e chore: update nix node_modules hashes 2026-07-30 09:11:45 +00:00
Aarav Sareen
c5cf416633
feat(app): fix composer attachment fades (#38547) 2026-07-30 17:07:35 +08:00
Aarav Sareen
3dbce3dade
feat(app): adds fades to file tree (#38549) 2026-07-30 17:07:06 +08:00
opencode-agent[bot]
a277557a5f chore: generate 2026-07-30 09:05:12 +00:00
Aarav Sareen
005bf088c9
feat(app): fix file tree button bg (#38629) 2026-07-30 17:03:23 +08:00
Aarav Sareen
eca5e68a5e
feat(app): disallow duplicate attachments (#39464) 2026-07-30 17:02:15 +08:00
Aarav Sareen
5b11635d97
feat(app): always show new session button (#39520)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:58:38 +08:00
Aarav Sareen
a9eda2e99e
feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-30 08:57:19 +00:00
Aarav Sareen
73009f0b09
feat(app): update arrow buttons in review tab top bar (#39469)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:56:16 +08:00
Frank
f720490219 fix(console): add public fetch compatibility flag 2026-07-30 00:40:52 -04:00
Deven Navani
341c64cc97
feat: discover Modal models (#39066) 2026-07-29 23:06:19 -05:00
opencode-agent[bot]
1e17856ba4 chore: update nix node_modules hashes 2026-07-29 18:06:26 +00:00
Lakshya Agarwal
a6f7fe739c
docs(ecosystem): add opencode-tavily plugin (#38709) 2026-07-29 12:52:12 -05:00
Vladimir Glafirov
02f50a6e80
chore: bump gitlab-ai-provider to 6.12.1 (#39490) 2026-07-29 12:45:40 -05:00
opencode-agent[bot]
7565e03536 chore: generate 2026-07-29 08:36:03 +00:00
usrnk1
035a5a1a67
feat(desktop): refine tab states (#39472) 2026-07-29 08:34:37 +00:00
Brendan Allan
e8b0992788
fix(app): defer model variant selector (#39445) 2026-07-29 05:16:30 +00:00
opencode-agent[bot]
8cbea4fbb7 chore: generate 2026-07-29 01:26:18 +00:00
Brendan Allan
f256a4c538
fix(app): preserve agent picker for existing users (#39300) 2026-07-29 01:24:31 +00:00
opencode
a45c2b917e sync release versions for v1.18.9 2026-07-28 18:14:59 +00:00
opencode-agent[bot]
f28d72d15e chore: update nix node_modules hashes 2026-07-28 17:12:42 +00:00
opencode-agent[bot]
d8a9d8a76d chore: generate 2026-07-28 16:55:30 +00:00
Aiden Cline
982a9044c5
fix(mcp): restore legacy SDK compatibility (#39373) 2026-07-28 11:53:35 -05:00
opencode-agent[bot]
7edefb3347
chore(mcp): upgrade client to 2.0.0 (#39369)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-28 11:37:58 -05:00
usrnk1
017a5977d2
feat(desktop): remove v2 vertical menu borders (#39317) 2026-07-28 19:50:58 +08:00
opencode-agent[bot]
7336cc5a7b chore: update nix node_modules hashes 2026-07-28 11:21:16 +00:00
opencode-agent[bot]
e1587588c4 chore: generate 2026-07-28 11:09:22 +00:00
opencode-agent[bot]
be93cec9ba
fix(app): guard reentrant Solid cleanup (#39261)
Co-authored-by: Jack <jack@anoma.ly>
2026-07-28 19:07:19 +08:00
Robin Andrew
7c898249a2
fix(desktop): patch @dnd-kit/solid to preserve core scroll plugins (#38119)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 19:05:57 +08:00
Brendan Allan
9e432a6785
feat(desktop): add opt-in v2 sidecar (#39286) 2026-07-28 17:29:27 +08:00
Brendan Allan
1ead8d84a2
fix(app): localize home session suspense (#39285) 2026-07-28 16:28:59 +08:00
Brendan Allan
8c81f9a40b
refactor(app): extract v2 settings controllers (#39228) 2026-07-28 16:12:59 +08:00
usrnk1
95636bd3ca
feat(desktop): collapse model provider sections (#39283) 2026-07-28 07:35:57 +00:00
opencode-agent[bot]
7dae9a1083 chore: generate 2026-07-28 07:03:56 +00:00
Brendan Allan
d46be565aa
refactor(app): thin new session composition (#39227) 2026-07-28 15:02:26 +08:00
opencode
8021dbd80f sync release versions for v1.18.8 2026-07-28 06:07:51 +00:00
opencode-agent[bot]
c8487bac54 chore: update nix node_modules hashes 2026-07-28 05:50:46 +00:00
Aiden Cline
484f00ebf4
fix(mcp): recover expired SDK sessions (#39265) 2026-07-28 00:35:19 -05:00
Aiden Cline
c3be6c4965
fix(mcp): honor callback port in debug (#39259) 2026-07-28 00:00:38 -05:00
opencode-agent[bot]
172d08cb98 chore: update nix node_modules hashes 2026-07-28 04:34:03 +00:00
opencode-agent[bot]
5e39051183
fix(provider): omit deprecated Gemini sampling defaults (#38924)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-27 23:21:00 -05:00
opencode-agent[bot]
d50b9e8f9a chore: generate 2026-07-28 04:20:55 +00:00
Aiden Cline
921b1c6a34
feat(mcp): upgrade client SDK to v2 (#39247)
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
2026-07-27 23:19:33 -05:00
Brendan Allan
9c8060d96d
refactor(app): extract keybind settings controller (#39226) 2026-07-28 12:18:27 +08:00
Brendan Allan
87db7a7f0a
fix(app): follow visual tab order (#39241) 2026-07-28 03:59:30 +00:00
opencode-agent[bot]
237e694df0
fix(app): expand Windows file tree folders (#39249)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-28 03:56:40 +00:00
opencode-agent[bot]
3f9dad3fd0 chore: update nix node_modules hashes 2026-07-28 01:57:02 +00:00
opencode-agent[bot]
3cc70160de chore: generate 2026-07-28 01:43:55 +00:00
Brendan Allan
b72d7b6fa2
refactor(app): separate model selector controller and view (#39084) 2026-07-28 01:42:14 +00:00
Vladimir Glafirov
029f5aa3a0
chore: bump gitlab-ai-provider to 6.12.0 (#39096) 2026-07-27 20:40:48 -05:00
Brendan Allan
284e97068c
fix(app): use blue for server status attention (#39217) 2026-07-28 01:16:18 +00:00
opencode-agent[bot]
970c38257c chore: generate 2026-07-28 01:11:56 +00:00
Brendan Allan
c39ad38424
fix(app): refresh global provider state (#39220) 2026-07-28 01:09:45 +00:00
opencode-agent[bot]
91508f6c88 chore: generate 2026-07-28 00:26:37 +00:00
Andrei Dziahel
1274408b89
fix(desktop): install AppStream metainfo in Linux packages (#36872)
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
2026-07-28 08:24:20 +08:00
Luke Parker
7ae1be5bf6
fix(desktop): use channel database in local runs (#39209) 2026-07-28 10:23:43 +10:00
opencode-agent[bot]
40e4d730ca
fix(console): avoid Safari brand icon repaint (#39154)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 15:57:55 -04:00
OpeOginni
8dc539b4ae
fix(core): correct MCP environment field in built-in skill (#39175) 2026-07-27 14:52:16 -05:00
Brendan Allan
8e8d38c3cd
fix(app): prevent freeze when closing projects (#39158) 2026-07-27 18:40:30 +00:00
Jack
a9bebd3653
docs(zen): add Kimi K3 (#39152) 2026-07-28 01:04:04 +08:00
opencode-agent[bot]
78f5764319
fix(console): stabilize brand download icons in Safari (#39151)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 12:58:19 -04:00
usrnk1
55b0211690
feat(desktop): use bg layer 4 for v2 switch (#39085) 2026-07-27 22:23:54 +08:00
Brendan Allan
4f62295e44
fix(app): contain right panel suspense (#39080) 2026-07-27 09:56:13 +00:00
Brendan Allan
e1c41941a9
refactor(app): extract tab rename adapter (#39079) 2026-07-27 09:54:24 +00:00
opencode-agent[bot]
0b4edfc64c chore: generate 2026-07-27 09:17:56 +00:00
Brendan Allan
4469cecc4e
refactor(app): move prompt effects into controller (#39073) 2026-07-27 17:15:57 +08:00
Brendan Allan
b06768aa8e
feat(app): add project selector shortcut (#39074) 2026-07-27 17:15:35 +08:00
opencode
bc2d3df05f sync release versions for v1.18.7 2026-07-27 07:32:58 +00:00
Luke Parker
35075bb466
fix(desktop): install Electron before development (#39052) 2026-07-27 14:37:42 +10:00
Luke Parker
a9afed0515
fix(desktop): remove titlebar inset in fullscreen (#38793) 2026-07-27 03:54:54 +00:00
Brendan Allan
09afffeead
fix(app): preserve shadowed command owners (#39044) 2026-07-27 03:45:18 +00:00
opencode-agent[bot]
de78da8a23 chore: generate 2026-07-27 03:06:34 +00:00
Brendan Allan
9ca2b44235
Connect provider e2e test (#39039) 2026-07-27 11:05:21 +08:00
opencode-agent[bot]
7ffc22c0ef chore: generate 2026-07-27 02:49:22 +00:00
David Siewert
759f6afa80
fix(app): add scroll to project selector dropdown (#39016)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:48:01 +08:00
opencode
1ad63cfc6c sync release versions for v1.18.6 2026-07-27 02:47:42 +00:00
Rahul A Mistry
7d8195e8fe
fix(ui): keep mutable selects open (#39027)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:46:59 +08:00
opencode-agent[bot]
7534d23551 chore: update nix node_modules hashes 2026-07-25 08:21:12 +00:00
Brendan Allan
0a6637e17a
chore(app): vendor v2 client snapshot (#38818) 2026-07-25 08:07:13 +00:00
Brendan Allan
2b2b69d668
fix(app): refresh V1 MCP state (#38816) 2026-07-25 15:01:18 +08:00
opencode-agent[bot]
9e8b2171a5
fix(app): refresh V1 providers after auth (#38786)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-25 14:58:05 +08:00
opencode-agent[bot]
5e2a6257b2 chore: generate 2026-07-25 01:25:04 +00:00
Kit Langton
065dc274ec
fix(core): branch-keyed repository cache with gated reference readiness (#38759) 2026-07-24 21:23:44 -04:00
opencode
a85d8d23aa sync release versions for v1.18.5 2026-07-24 22:18:13 +00:00
Aiden Cline
2b2aacc939
fix(provider): generalize Claude adaptive thinking (#38757) 2026-07-24 16:44:00 -05:00
Aiden Cline
7840562d1b
fix(llm): revert response message phases (#38761) 2026-07-24 16:31:38 -05:00
opencode-agent[bot]
53669cab2b chore: generate 2026-07-24 21:07:01 +00:00
Aiden Cline
4b19ea2a71
fix(llm): preserve response message phases (#38452) 2026-07-24 16:05:22 -05:00
Frank
553b42fcc7 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-24 14:15:55 -04:00
Frank
e62b09e6fc zen: opus 5 2026-07-24 14:15:53 -04:00
Zach Bruggeman
f51665191a
fix(opencode): preserve grep symlink paths (#38581)
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
2026-07-24 12:46:53 -05:00
Zach Bruggeman
e63996919b
fix(opencode): preserve grep symlink paths (#38581)
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
2026-07-24 11:16:58 -05:00
Brendan Allan
909db63265
fix(app): restore optimistic timeline state (#38693) 2026-07-24 21:20:13 +08:00
opencode-agent[bot]
66495a2a22 chore: generate 2026-07-24 11:03:00 +00:00
Brendan Allan
9ba82a1b8c
fix(app): gate legacy server features (#38651)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-24 18:57:51 +08:00
Brendan Allan
b62806683e
fix(app): preserve inline file mentions (#38663) 2026-07-24 18:28:09 +08:00
opencode-agent[bot]
ae4be983cb chore: generate 2026-07-24 10:22:28 +00:00
Brendan Allan
ad78ef5a4c
feat(app): support current pty transport (#38463) 2026-07-24 18:20:54 +08:00
Brendan Allan
67a04787bf
fix(app): gate config permission auto-accept (#38650) 2026-07-24 18:15:42 +08:00
Brendan Allan
aaa42fe3bf
fix(app): isolate v2 servers from legacy layout (#38649) 2026-07-24 17:56:39 +08:00
opencode-agent[bot]
3337495427 chore: generate 2026-07-24 09:13:59 +00:00
Brendan Allan
80a4fe8f39
fix(app): remove diff rendering from file-specific tabs (#38662) 2026-07-24 17:12:23 +08:00
Brendan Allan
91ed2567ef
refactor(app): resolve server protocol state (#38648) 2026-07-24 16:35:10 +08:00
opencode-agent[bot]
c4545ab12f chore: generate 2026-07-24 08:29:45 +00:00
Devin R Leopold
57ddfeb756
fix(app): classify existing web profiles for layout transition (#38117)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-24 08:28:27 +00:00
Brendan Allan
ce7f54d5e7
fix(app): make prompt input agent toggle reactive (#38653) 2026-07-24 16:12:57 +08:00
opencode-agent[bot]
bce2992729 chore: generate 2026-07-24 07:48:56 +00:00
Brendan Allan
3819848cf2
feat(app): support current review data (#38460) 2026-07-24 15:47:29 +08:00
Brendan Allan
55f4a2691a
fix(app): preserve paginated timeline order (#38641) 2026-07-24 15:44:03 +08:00
Brendan Allan
a48912cbb1
fix(app): restore directory-scoped session status for v1 servers (#38637) 2026-07-24 14:55:54 +08:00
opencode-agent[bot]
2ea4bb793e chore: generate 2026-07-24 06:40:05 +00:00
Brendan Allan
d07323ef59
feat(app): migrate discovery workflows (#38465) 2026-07-24 14:38:39 +08:00
opencode-agent[bot]
5ce89dc2ad chore: generate 2026-07-24 06:07:04 +00:00
Brendan Allan
589ef16128
refactor(app): split home view controllers (#38607) 2026-07-24 06:05:40 +00:00
opencode-agent[bot]
386afb77e0 chore: generate 2026-07-24 04:59:34 +00:00
Brendan Allan
29af2e39ff
feat(app): migrate session interactions (#38461) 2026-07-24 12:58:20 +08:00
opencode-agent[bot]
090a26a301 chore: generate 2026-07-24 03:58:09 +00:00
Brendan Allan
ce9a875181
feat(app): render current session timeline (#38466) 2026-07-24 11:56:48 +08:00
Brendan Allan
db88c42335
fix(app): hydrate v1 session progress (#38606) 2026-07-24 11:39:40 +08:00
1137 changed files with 128540 additions and 17381 deletions

View file

@ -10,6 +10,7 @@ kitlangton
kommander
ludvigrask
MrMushrooooom
neriousy
nexxeln
R44VC0RP
rekram1-node
@ -19,3 +20,4 @@ Slickstef11
usrnk1
vimtor
StarpTech
vaprdev

View file

@ -15,12 +15,15 @@ runs:
with:
node-version: "24"
- name: Get baseline download URL
- name: Get Bun version and baseline download URL
id: bun-url
shell: bash
run: |
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
# Bun 1.4.2 includes the patched peer-variant fix in oven-sh/bun#33646.
if [ "$RUNNER_OS" = "Windows" ]; then V=1.4.2; fi
echo "version=$V" >> "$GITHUB_OUTPUT"
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
@ -30,9 +33,10 @@ runs:
fi
- name: Setup Bun
id: setup-bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-version: ${{ !steps.bun-url.outputs.url && steps.bun-url.outputs.version || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
@ -45,24 +49,16 @@ runs:
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies
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 ${{ inputs.install-flags }}
else
bun install ${{ inputs.install-flags }}
fi
run: bun install ${{ inputs.install-flags }}
shell: bash
- name: Save Bun dependencies
@ -70,4 +66,4 @@ runs:
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-${{ hashFiles('**/bun.lock') }}

View file

@ -1,37 +0,0 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts

View file

@ -35,6 +35,7 @@ jobs:
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}

View file

@ -54,7 +54,7 @@ jobs:
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
opencode run -m opencode/gpt-6-luna --variant high "A new issue has been created:
Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
@ -188,7 +188,7 @@ jobs:
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
opencode run -m opencode/gpt-6-luna --variant high "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.

62
.github/workflows/models-snapshot.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: models-snapshot
permissions:
contents: write
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * *"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
refresh:
if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
ref: v2
# The refresh script only needs `fetch`; skip the monorepo install.
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Setup git committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Refresh snapshot
run: bun packages/core/script/update-models-snapshot.ts
- name: Commit changes
run: |
set -euo pipefail
FILE="packages/core/src/models-dev/snapshot.txt"
if [ -z "$(git status --short -- "$FILE")" ]; then
echo "No changes to commit"
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
git add "$FILE"
git commit -m "chore(core): refresh bundled models.dev snapshot"
git pull --rebase --autostash origin v2
git push origin HEAD:v2 --no-verify
echo "### models.dev snapshot" >> "$GITHUB_STEP_SUMMARY"
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"

View file

@ -219,7 +219,6 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
@ -316,16 +315,14 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
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
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@ -343,8 +340,7 @@ jobs:
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 }}
CSC_KEYCHAIN: build.keychain
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

View file

@ -49,7 +49,7 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
opencode run --agent triage "The following issue was just opened, triage it:
opencode run --agent triage --variant high "The following issue was just opened, triage it:
Title: $ISSUE_TITLE

52
.github/workflows/unlock.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: unlock
on:
workflow_dispatch:
inputs:
stage:
description: SST stage to unlock
required: true
type: choice
options:
- dev
- production
concurrency: deploy-${{ inputs.stage }}
permissions:
contents: read
id-token: write
jobs:
unlock:
runs-on: ubuntu-latest
environment: ${{ inputs.stage }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
aws-region: us-east-1
- run: bun sst unlock --stage=${{ inputs.stage }}
env:
GITHUB_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ inputs.stage == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: unlock@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: unlock@${{ github.sha }}

1
.gitignore vendored
View file

@ -32,3 +32,4 @@ UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo
backups/

View file

@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-mini
model: opencode/gpt-6-luna
color: "#44BA81"
tools:
"*": false

View file

@ -1,5 +1,5 @@
---
model: opencode/gpt-5.4
model: opencode/gpt-6-luna
---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.

View file

@ -1,6 +1,6 @@
---
description: translate English to other languages
model: opencode/gpt-5.6-sol
model: opencode/gpt-6-sol
---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.

View file

@ -0,0 +1,63 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)

View file

@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const

View file

@ -0,0 +1,64 @@
# Change Log — 2026-07-26
## Desktop microphone voice control
### Summary
Added built-in push-to-talk / toggle microphone voice control to the OpenCode Electron desktop app for Windows (and other desktop platforms). Speech is transcribed with cloud STT providers (Deepgram streaming, Groq Whisper, OpenAI Whisper). Short spoken phrases can be interpreted as voice commands.
### Architecture
- **Main process** (`packages/desktop/src/main/voice/`): recording orchestration, SoX/FFmpeg capture when available, cloud STT providers, global hotkey (`Alt+Space` by default), IPC + electron-store config.
- **Renderer fallback capture**: when SoX/FFmpeg is not installed, the renderer uses `getUserMedia`, downsamples to 16 kHz mono PCM, and streams chunks to the main process.
- **App UI** (`packages/app/src/voice/`): SolidJS voice context, mic button beside prompt submit, recording overlay, settings tab, command parser.
- **Config schema** (`packages/opencode/src/config/voice.ts`): Effect Schema for voice settings.
### User setup
1. Set one of: `DEEPGRAM_API_KEY`, `GROQ_API_KEY`, or `OPENAI_API_KEY` in the environment.
2. Optional: install SoX (`winget install sox`) for native capture; otherwise renderer MediaDevices is used automatically.
3. In Desktop Settings → Voice, choose provider/hotkey and enable commands.
4. Press the mic button or the global hotkey to dictate. Say `send`, `clear`, `stop`, etc. for commands.
### Files added
- `packages/desktop/src/main/voice/*`
- `packages/desktop/docs/voice-control.md`
- `packages/app/src/voice/*`
- `packages/opencode/src/config/voice.ts`
- `CHANGELOGS/2026-07-26-desktop-voice-control.md`
- `backups/20260726-121936/*` (pre-change backups)
### Files modified
- Desktop main/preload/renderer IPC surface
- Prompt input V2 (mic button + voice action wiring)
- Settings dialog (Voice tab)
- UI icon sets (microphone)
- App i18n dictionaries
- Permission dock (`data-action` hooks for voice accept/reject)
### Notes
- Existing npm plugin `opencode-voice` remains useful for CLI/TUI; this feature targets the Electron desktop app where plugins cannot access mic/DOM.
- Electron `globalShortcut` only fires on keydown, so the hotkey toggles recording (press to start, press again to stop).
## Production installation — 2026-07-30
- Repaired the Windows checkout declaration shim used by app typechecking.
- Corrected voice recorder child-process typing, settings component contracts, public voice type naming, and resolved config typing.
- Verified `packages/desktop`, `packages/app`, `packages/opencode`, `packages/ui`, and `packages/session-ui` with their package typecheck commands.
- Verified the voice command parser with four passing unit tests.
- Built with `OPENCODE_CHANNEL=prod` and packaged `dist/opencode-desktop-win-x64.exe`.
- Installer SHA-256: `E4E4C855FB17A9027B993EFB1C59DB8DBC589A23EA9FB171F7401D359913D4EC`.
- Installed silently for the current user over OpenCode Desktop 1.18.4; the installer returned exit code 0.
- Confirmed the existing user-data backup at `C:\Users\axcho\OneDrive\Documents\Business\CloudAxc.io\Code\OpenCode\opencode-userdata-backup-20260727`.
## CloudAxe repository publication — 2026-07-30
- Added `backups/` to root `.gitignore` so local timestamped safety copies stay untracked.
- Re-verified package typechecks for `desktop`, `app`, `opencode`, `ui`, and `session-ui`, plus voice command parser unit tests.
- Committed voice control source changes on `dev` as `feat(desktop): add voice control` (`78bf9a35eb50e183128a003ce8ef3a5c8b6bb359`).
- Added remote `cloudaxe` → `https://repo.cloudaxe.io/CloudAxe/cloudaxe-opencode.git`; left GitHub `origin` unchanged.
- Direct full-history HTTP push hit server `413 Request Entity Too Large`; seeded history via Gitea migrate from `anomalyco/opencode`, then force-with-lease updated `cloudaxe/dev` to the local voice tip.
- Verified `cloudaxe/dev` SHA matches local `dev` (`78bf9a35eb50e183128a003ce8ef3a5c8b6bb359`).

View file

@ -0,0 +1,26 @@
# 2026-08-25 — Fix Add Server form field entry and save
## Summary
Fixed the Settings Add/Edit Server form so name, username, and password can be typed and persisted on save.
## Changes
### `packages/app/src/components/settings-v2/dialog-server-v2.tsx`
- Initialize add/edit form state during setup (before first paint) so uncontrolled defaults apply correctly.
- Keep URL controlled for validation/preview; make name, username, and password write-once initial values (plain strings, not reactive) so typed characters are not rolled back on Windows/Electron.
- Stop disabling text fields while the health check runs; only the submit button stays disabled.
- Allow Cancel during a pending check.
### `packages/app/src/components/dialog-select-server.tsx`
- Persist username independently of password on add (aligned with edit), so credentials are not dropped when only one is set.
- Remove `isPending` early-returns from field change handlers so input continues updating the store during health checks.
- Keep optional legacy `ServerForm` fields enabled while checking; only the submit button is disabled.
## Verification
- `bun typecheck` in `packages/app`
- Rebuilt `packages/desktop` (`OPENCODE_CHANNEL=prod bun run build` + `npx electron-builder --win --publish never`).
- Reinstalled the Windows app from `packages/desktop/dist/opencode-desktop-win-x64.exe`; verified installed `app.asar` timestamp updated and app relaunched.

View file

@ -0,0 +1,66 @@
# 2026-08-25 — Upstream OpenCode desktop sync
## Summary
Merged latest upstream `origin/dev` (`anomalyco/opencode`) into the CloudAxe fork `dev` branch while preserving CloudAxe voice-control commits.
## Upstream
- Remote: `origin` → `https://github.com/anomalyco/opencode.git`
- Target: `origin/dev` @ `8615731d46` (`fix(console): rate limit checkout session creation (#45007)`)
- Fork point / merge-base: `0317531906` (`zen: add laguna-2-2.1`)
- Approx. upstream commits integrated: ~467
## Preserved CloudAxe commits
- `78bf9a35eb` — `feat(desktop): add voice control`
- `9f5a50a369` — `docs(desktop): note CloudAxe voice fork publication`
## Conflict resolution strategy
Keep CloudAxe voice-control additions; accept upstream changes for everything else. Never drop upstream keys/symbols.
### Resolved conflict files
| File | Resolution |
|------|------------|
| `packages/app/src/app.tsx` | Kept `VoiceProvider` + `VoiceOverlay`; matched upstream provider tree (no `MarkedProvider`) |
| `packages/app/src/components/prompt-input-v2.tsx` | Kept voice action registration + `VoiceButton`; dropped obsolete `useCommands` / `useEditHandler`; kept upstream imports (`onCleanup` retained for voice cleanup) |
| `packages/app/src/components/settings-v2/dialog-settings-v2.tsx` | Combined imports: `createMemo` (upstream) + `Show` (voice settings gate) |
| `packages/desktop/src/preload/index.ts` | Kept both `setNativeTranslations` (upstream) and `voice` IPC API (CloudAxe) |
| `packages/desktop/src/preload/types.ts` | Kept both `setNativeTranslations` and `voice: VoiceAPI` |
| `packages/session-ui/src/v2/components/prompt-input/index.tsx` | Kept both `actions` (voice button slot) and `variantControlVisible`; used upstream i18n send/stop labels |
| `packages/app/src/i18n/da.ts` | Upstream locale + CloudAxe voice keys |
| `packages/app/src/i18n/de.ts` | Upstream locale (`satisfies Partial<Record<Keys, string>>`) + CloudAxe voice keys |
| `packages/app/src/i18n/fr.ts` | Upstream locale + CloudAxe voice keys |
| `packages/app/src/i18n/ja.ts` | Upstream locale + CloudAxe voice keys |
| `packages/app/src/i18n/ko.ts` | Upstream locale + CloudAxe voice keys |
Auto-merged locales that already retained voice keys: `ar`, `br`, `bs`, `en`, `es`, `no`, `pl`, `ru`, `th`, `tr`, `uk`, `zh`, `zht`.
## Follow-up fixes for pre-push typecheck
- Updated `packages/app/src/voice/voice-settings.tsx` to import `ExternalLink` from `@/components/external-link` (upstream renamed `link.tsx`).
- Ran `bun install` after the merge so vendored `@opencode-ai/client` (with `./promise` export) is linked.
- Repaired incomplete local installs that blocked monorepo typecheck on Windows/OneDrive (`drizzle-orm`, `marked-shiki` peer `marked@18`, enterprise `custom-elements.d.ts` symlink).
- Aligned `bun.lock` desktop `drizzle-orm` entry to `catalog:`.
## Verification
- `bun typecheck` (root turbo, 30 packages) — passed
- `bun typecheck` in `packages/opencode` — passed
- `bun typecheck` in `packages/desktop` — passed
- Pushed to `cloudaxe/dev` (`9f5a50a369..995b823ee4`)
- No remaining `<<<<<<<` / `>>>>>>>` conflict markers
- Voice module paths still present: `packages/app/src/voice/`, `packages/desktop/src/main/voice/`
## Resulting commits on `dev`
- `fc2c29bd3a` — `chore: merge upstream opencode desktop changes`
- `851e958191` — `fix(desktop): adapt voice settings to upstream ExternalLink`
- `995b823ee4` — `chore: use catalog drizzle-orm in lockfile`
## Integration method
- Merge commit (no rebase, no force-push)
- Safety branch before merge: `backup-pre-upstream-sync`

View file

@ -0,0 +1,52 @@
# 2026-09-29 — Upstream OpenCode desktop sync
## Summary
Merged latest upstream `origin/dev` (`anomalyco/opencode`) into the CloudAxe fork `dev` branch. The merge was clean. Voice-control modules and their integration hooks stayed in place.
## Upstream
- Remote: `origin` → `https://github.com/anomalyco/opencode.git`
- Target: `origin/dev` @ `7945de208964` (`Merge branch 'dev' of github.com:anomalyco/opencode into dev`, 2026-09-28)
- Previous sync point: `8615731d4615` (2026-08-25)
- Desktop package version: `1.18.23` → `1.18.33`
- Approx. upstream commits integrated: 263
- Electron stayed at `42.3.3`
## Resulting commit
- `d2d1e2b685` — `chore: merge upstream opencode desktop changes`
- Safety branch before merge: `backup-pre-upstream-sync-20260929`
- Integration method: merge commit (`--no-ff`), no rebase, no force-push
- No conflict markers
## Voice control
Voice remains fork-only. Upstream still has no equivalent. Preserved paths:
- `packages/app/src/voice/`
- `packages/desktop/src/main/voice/`
- `packages/opencode/src/config/voice.ts`
- App shell `VoiceProvider`, prompt microphone button (`data-action="prompt-voice"`), Voice settings copy, preload `voice:*` IPC
## Verification
- `bun install` from the repo root
- Repaired a truncated `ai-gateway-provider@3.2.0` extract that left `package.json` missing, then installed again
- `bun typecheck` in `packages/desktop` — passed
- `bun typecheck` in `packages/app` — passed
- `bun typecheck` in `packages/opencode` — passed after the package repair
- Voice command parser: 4 passing tests (`send`, `clear`, longer phrases, commands disabled)
- Production renderer and main bundles contain the Voice settings title, the prompt microphone action, and the voice IPC handlers including toggle and default hotkey `Alt+Space`
## Windows package
- Built with `OPENCODE_CHANNEL=prod`
- In-repo `electron-builder` output failed with `EPERM` renaming `dist/win-unpacked.tmp` on OneDrive
- Installer written outside the repo: `C:\Users\axcho\AppData\Local\Temp\opencode-desktop-1.18.33\opencode-desktop-win-x64.exe`
- Copy in the checkout: `packages/desktop/dist/opencode-desktop-win-x64.exe`
- Size: 130,688,374 bytes
- SHA-256: `F78664903A7346F140153F8F34668946A28156DBEF581AFEFCBBE96EFD9E580D`
- Installed silently for the current user with the one-click installer (`/S`)
- Installed app: `%LOCALAPPDATA%\Programs\@opencode-aidesktop\OpenCode.exe`, file version `1.18.33`
- Live microphone capture, global hotkey, and cloud transcription were not exercised; those need the desktop session, a microphone, and a speech-provider API key

View file

@ -0,0 +1,24 @@
# 2026-10-03 — CACT server deploy playbook
## Summary
Added an operator playbook for deploying the CloudAxe Coder Team (CACT) on a new Linux server: headless OpenCode on port 4096, the `cact-*` agent pack, provider auth, systemd, firewall/TLS notes, health checks, desktop connection, and upgrade/rollback.
## Changes
### Added
- [`docs/cact-server-playbook.md`](../docs/cact-server-playbook.md) — repeatable runbook with placeholders only (no live secrets). Covers:
- Target shape (`opencode serve`, basic auth, XDG paths)
- Host bootstrap (packages, service user, fork install)
- CACT agent inventory and provider mapping
- OpenRouter callout for `cact-documentation-agent`
- Secrets (`server.env`, `auth.json`)
- systemd unit and rollback
- Network hardening and optional Nginx TLS
- Verification via `/global/health`, `/api/location`, and CloudAxe Desktop
- Upgrade snapshot procedure and ops checklist
## Scope
Documentation only. No runtime, agent-definition, or config code changes in this change set.

View file

@ -157,7 +157,7 @@ Caveats:
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts`
Other tips and tricks:
@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:

452
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,414 @@
# CACT server deploy playbook
Repeatable runbook for standing up the **CloudAxe Coder Team (CACT)** on a new Linux server: headless OpenCode, the `cact-*` agent pack, provider credentials, systemd supervision, and a CloudAxe desktop connection check.
Use placeholders only. Do not commit live IPs, passwords, or API keys.
## What you are deploying
| Piece | Role |
| --- | --- |
| OpenCode headless server | `opencode serve` HTTP API on port `4096` |
| CACT agents | `~/.config/opencode/agents/cact-*.md` |
| Global rules | `~/.config/opencode/AGENTS.md` |
| Provider auth | `~/.local/share/opencode/auth.json` (never in git) |
| Clients | CloudAxe / OpenCode Desktop (Settings → Add Server), or `curl` / SDK |
`cact-` means **CloudAxe Coder Team**. The normal entry agent is `cact-orchestrator-tech-lead`.
Paths follow XDG defaults (`xdg-basedir`):
| Kind | Default path (Linux) |
| --- | --- |
| Config | `~/.config/opencode` |
| Data (auth, logs) | `~/.local/share/opencode` |
| State | `~/.local/state/opencode` |
| Cache | `~/.cache/opencode` |
Override the config directory with `OPENCODE_CONFIG_DIR` only when you intentionally isolate installs.
## Assumptions
- Target OS: Ubuntu 22.04/24.04 or Debian 12 (amd64 or arm64).
- You have SSH access and can use `sudo`.
- You will use a **dedicated service user** (example: `opencode`).
- The CloudAxe desktop expects the **same OpenCode build** as this fork (v2 API). Do not deploy an unrelated older package and assume compatibility.
- Provider policy: **direct vendor APIs** (Moonshot, DeepSeek, Z.ai). Avoid OpenRouter for CACT agents.
## Pre-flight
Before changing anything on the host:
1. Confirm access scope (sudo / root).
2. Name the environment: `dev` / `staging` / `production`.
3. Note existing listeners on `4096` (`ss -tlnp | grep 4096`).
4. Decide whether port `4096` is exposed raw or only via Nginx TLS on `443`.
5. Prepare a rollback posture: previous binary path, config tarball location, and how to stop the unit.
Destructive classification for a first install: **risky** (new systemd unit, firewall rules, public listener). Treat password/key rotation and unit replacement on a live host as **destructive** unless a verified backup and rollback path exist.
---
## 1. Target shape
```text
Internet / operator LAN
│
▼
[optional Nginx TLS :443]
│
▼
opencode serve
--hostname 0.0.0.0 # or 127.0.0.1 behind Nginx
--port 4096
+ OPENCODE_SERVER_PASSWORD
+ OPENCODE_SERVER_USERNAME (optional; default opencode)
│
▼
~/.config/opencode/agents/cact-*.md
~/.local/share/opencode/auth.json
```
CLI flags (see `packages/opencode/src/cli/cmd/serve.ts` and `packages/opencode/src/cli/network.ts`):
| Flag | Purpose | Typical CACT value |
| --- | --- | --- |
| `--port` | Listen port | `4096` |
| `--hostname` | Bind address | `0.0.0.0` (direct) or `127.0.0.1` (behind proxy) |
| `--cors` | Extra browser origins | Desktop / app origins as needed |
| `--mdns` | LAN discovery | **off** on a VPS |
If `OPENCODE_SERVER_PASSWORD` is unset, the server starts **unsecured** and prints a warning. Always set it for any network-reachable host.
---
## 2. Host bootstrap
Run as a sudo-capable operator.
### 2.1 Packages
```bash
sudo apt-get update
sudo apt-get install -y git curl ca-certificates ripgrep build-essential unzip
```
Install Bun (required to build/run from this repo):
```bash
curl -fsSL https://bun.sh/install | bash
# ensure ~/.bun/bin is on PATH for the service user as well
```
### 2.2 Service user
```bash
sudo useradd --system --create-home --shell /bin/bash opencode
sudo -u opencode -H bash -lc 'mkdir -p ~/.config/opencode/agents ~/.local/share/opencode'
```
### 2.3 Clone and install this fork
Replace `YOUR_FORK_URL` and pin a known-good ref (tag or commit SHA).
```bash
sudo -u opencode -H bash <<'EOF'
set -euo pipefail
cd ~
git clone YOUR_FORK_URL opencode
cd opencode
git checkout YOUR_PINNED_REF
bun install
cd packages/opencode
bun run build # or the package's documented production build script
EOF
```
Install a durable binary on `PATH` for the service user (example: copy the built CLI into `~/bin`):
```bash
sudo -u opencode -H bash <<'EOF'
set -euo pipefail
mkdir -p "$HOME/bin"
# Adjust source path to match the build output for your platform
cp -f "$HOME/opencode/packages/opencode/dist/opencode-linux-"*/bin/opencode "$HOME/bin/opencode"
chmod 755 "$HOME/bin/opencode"
"$HOME/bin/opencode" --version
EOF
```
Record the version string in your ops notes. The CloudAxe desktop must speak the same API generation as this server.
Alternative: install a prebuilt musl binary that matches this fork’s release artifacts (see `packages/opencode/Dockerfile`), then still deploy CACT config as below.
---
## 3. Deploy the CACT agent pack
Copy from a trusted workstation or private backup into the service user’s config. Source of truth on the operator machine is typically:
```text
~/.config/opencode/AGENTS.md
~/.config/opencode/agents/cact-*.md
```
On the server:
```bash
# From an operator machine (example)
scp AGENTS.md opencode@SERVER:~/.config/opencode/
scp cact-*.md opencode@SERVER:~/.config/opencode/agents/
```
Expected agents:
| Agent file | Model (frontmatter) | Provider |
| --- | --- | --- |
| `cact-orchestrator-tech-lead.md` | `moonshotai/kimi-k3` | Moonshot |
| `cact-frontend-agent.md` | `moonshotai/kimi-k3` | Moonshot |
| `cact-backend-agent.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-database-agent.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-devops-server-engineer.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-security-agent.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-qa-agent.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-gatekeeper-agent.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-requirements-agent.md` | `zai/glm-5.2` | Z.ai |
| `cact-requirements-agent-glm-divergent.md` | `zai/glm-5.2` | Z.ai |
| `cact-research-agent.md` | `zai/glm-5.2` | Z.ai |
| `cact-research-agent-glm-divergent.md` | `zai/glm-5.2` | Z.ai |
| `cact-requirements-agent-deepseek.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-research-agent-deepseek.md` | `deepseek/deepseek-v4-pro` | DeepSeek |
| `cact-documentation-agent.md` | `openrouter/moonshotai/kimi-k2.6` | **Needs fix** |
**OpenRouter callout:** `cact-documentation-agent` still references OpenRouter. Before relying on that agent, change its frontmatter `model` to a direct Moonshot (or other approved direct) model ID and verify with `opencode models --refresh` on the server.
Optional global config (`~/.config/opencode/opencode.jsonc`) may enable MCP servers. Keep secrets out of that file.
Do **not** copy `auth.json`, `.env`, or systemd env files into the git repository.
---
## 4. Secrets and provider auth
### 4.1 Server basic auth env file
```bash
sudo install -d -m 0750 -o root -g opencode /etc/opencode
sudo tee /etc/opencode/server.env >/dev/null <<'EOF'
OPENCODE_SERVER_USERNAME=opencode
OPENCODE_SERVER_PASSWORD=REPLACE_WITH_LONG_RANDOM_SECRET
# Optional: pin HOME for the service if the unit does not set User=
# HOME=/home/opencode
EOF
sudo chmod 0640 /etc/opencode/server.env
sudo chown root:opencode /etc/opencode/server.env
```
Generate a strong password (`openssl rand -base64 32`). Store it in your password manager; do not paste it into tickets or git.
### 4.2 Provider credentials
As the service user, log in to each vendor API you need:
```bash
sudo -u opencode -H bash -lc 'export PATH="$HOME/bin:$HOME/.bun/bin:$PATH"; opencode auth login'
```
Credentials land in `~/.local/share/opencode/auth.json` under that user’s home. Restrict permissions:
```bash
sudo -u opencode -H bash -lc 'chmod 600 ~/.local/share/opencode/auth.json'
```
Minimum providers for the current CACT pack: Moonshot, DeepSeek, Z.ai. Refresh model lists after login:
```bash
sudo -u opencode -H bash -lc 'opencode models --refresh'
```
---
## 5. systemd unit
Create `/etc/systemd/system/opencode-cact.service`:
```ini
[Unit]
Description=OpenCode CACT headless server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opencode
Group=opencode
WorkingDirectory=/home/opencode
EnvironmentFile=/etc/opencode/server.env
Environment=HOME=/home/opencode
Environment=PATH=/home/opencode/bin:/home/opencode/.bun/bin:/usr/local/bin:/usr/bin
ExecStart=/home/opencode/bin/opencode serve --hostname 0.0.0.0 --port 4096
Restart=on-failure
RestartSec=5
# Harden lightly; expand after validating tool/workspace needs
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
If you terminate TLS at Nginx, bind OpenCode to loopback instead:
```ini
ExecStart=/home/opencode/bin/opencode serve --hostname 127.0.0.1 --port 4096
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now opencode-cact.service
sudo systemctl status opencode-cact.service --no-pager
sudo journalctl -u opencode-cact.service -n 50 --no-pager
```
### Rollout order
1. Validate binary: `opencode --version`
2. Validate env file permissions and that `OPENCODE_SERVER_PASSWORD` is set
3. `daemon-reload` → `restart` (or `start` on first install)
4. Run health probes (section 7)
5. Connect desktop
### Rollback
1. `sudo systemctl stop opencode-cact.service`
2. Restore previous binary into `/home/opencode/bin/opencode`
3. Restore config/auth from the timestamped snapshot (section 8)
4. `sudo systemctl start opencode-cact.service`
5. Re-run health probes
---
## 6. Network and hardening
### 6.1 Firewall
Allow `4096/tcp` only from operator addresses (example with UFW):
```bash
sudo ufw allow OpenSSH
sudo ufw allow from OPERATOR_CIDR to any port 4096 proto tcp
sudo ufw enable
sudo ufw status verbose
```
Cloud security groups / nftables should match the same allowlist.
### 6.2 Optional Nginx TLS
When the host must not expose raw HTTP:
1. Bind OpenCode to `127.0.0.1:4096`.
2. Proxy `https://cact.example.com` → `http://127.0.0.1:4096` with WebSocket upgrade headers if clients use SSE/WS.
3. Obtain certificates (Let’s Encrypt / ACME).
4. Point the desktop at `https://cact.example.com` with the same basic-auth username/password.
Keep mDNS disabled on public VPS hosts (`--mdns` unset / false).
### 6.3 Workspace directories
Clients send a project directory via request headers. Ensure the service user can read/write the repos you intend to work in (for example under `/home/opencode/workspaces`). Do not run the server as root.
---
## 7. Prove it
Replace placeholders. Username defaults to `opencode` unless you set `OPENCODE_SERVER_USERNAME`.
### 7.1 Authenticated HTTP
```bash
export CACT_URL='http://SERVER_IP:4096'
export CACT_USER='opencode'
export CACT_PASS='YOUR_SERVER_PASSWORD'
# Global health (many builds)
curl -fsS -u "$CACT_USER:$CACT_PASS" "$CACT_URL/global/health"
# v2 location probe (desktop also uses this when health routes are odd)
curl -fsS -u "$CACT_USER:$CACT_PASS" "$CACT_URL/api/location"
# OpenAPI explorer (optional)
# open "$CACT_URL/doc"
```
Desktop health checking (`packages/app/src/utils/server-health.ts`) tries, in order:
1. `/api/health`
2. `/global/health`
3. Successful `/api/location` (treat as healthy when health routes fail for a non-network reason)
A 404 on one health path with a working `/api/location` can still show **online** in CloudAxe Desktop.
### 7.2 Agent inventory on the host
```bash
sudo -u opencode -H bash -lc 'opencode agent' # or the install's list-agents command
# Confirm cact-orchestrator-tech-lead and other cact-* names appear
ls -1 /home/opencode/.config/opencode/agents/cact-*.md
```
### 7.3 CloudAxe Desktop
1. Open **Settings → Servers** (or Add Server).
2. URL: `http://SERVER_IP:4096` or your HTTPS proxy URL.
3. Username / password: same as `OPENCODE_SERVER_*`.
4. Save when health shows online.
5. Start a session with agent **`cact-orchestrator-tech-lead`**.
6. Confirm a trivial prompt returns (proves provider auth + model routing).
---
## 8. Upgrade
1. Snapshot:
```bash
TS=$(date +%Y%m%d-%H%M%S)
sudo -u opencode -H bash -lc "tar -C \$HOME -czf \$HOME/cact-backup-\$TS.tgz .config/opencode .local/share/opencode"
sudo cp -a /home/opencode/bin/opencode "/home/opencode/bin/opencode.prev-$TS"
```
2. Pull / rebuild / replace the binary at the pinned ref.
3. `sudo systemctl restart opencode-cact.service`
4. Re-run section 7 probes and a desktop session.
5. Keep the previous binary and tarball until the new build is confirmed.
---
## 9. Ops checklist (copy into the ticket)
- [ ] Service user created; OpenCode binary on PATH for that user
- [ ] `opencode --version` recorded
- [ ] `AGENTS.md` and all `cact-*.md` installed under `~/.config/opencode/`
- [ ] `cact-documentation-agent` model switched off OpenRouter (or agent unused)
- [ ] Provider logins present; `auth.json` mode `600`
- [ ] `/etc/opencode/server.env` mode `640`, password set
- [ ] `opencode-cact.service` enabled and active
- [ ] Firewall / security group allowlists `4096` (or only `443` via Nginx)
- [ ] `curl` health + `/api/location` succeed with basic auth
- [ ] Desktop Add Server shows online
- [ ] Session as `cact-orchestrator-tech-lead` succeeds
- [ ] Backup tarball + previous binary path documented for rollback
---
## References in this repo
- Serve command: `packages/opencode/src/cli/cmd/serve.ts`
- Network flags: `packages/opencode/src/cli/network.ts`
- Global path layout: `packages/core/src/global.ts`
- Desktop health probes: `packages/app/src/utils/server-health.ts`
- Upstream-style server docs: `packages/web/src/content/docs/server.mdx`
- Container packaging of the CLI binary: `packages/opencode/Dockerfile`

View file

@ -150,8 +150,9 @@ const zenLitePrice = new stripe.Price("ZenLitePrice", {
})
const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
// Use existing Go resources in dev's checkout Stripe account.
product: $app.stage === "dev" ? "prod_U1tUscpmwtV2bG" : zenLiteProduct.id,
price: $app.stage === "dev" ? "price_1T3phhE7fOCwHSD4zS6w2NPy" : zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
@ -221,6 +222,15 @@ const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
// Preview branches have independent databases; do not send their workspaces to shared dev.
const migrationDomain =
$app.stage === "production" ? "opencode.ai" : $app.stage === "dev" ? "dev.opencode.ai" : undefined
const consoleMigration = new sst.Linkable("ConsoleMigration", {
properties: {
consoleUrl: migrationDomain ? `https://${migrationDomain}/console` : "",
inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "",
},
})
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
@ -235,6 +245,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
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 ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@ -255,6 +266,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
consoleMigration,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,
@ -263,6 +275,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
@ -270,6 +283,7 @@ new sst.cloudflare.x.SolidStart("Console", {
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
new sst.Secret("CLOUDFLARE_ACCESS_CLIENT_ID"),
...ZEN_MODELS,
...($dev
? [
@ -288,8 +302,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
},
},
},

View file

@ -181,6 +181,16 @@ const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
},
})
const r2SqlAuthToken = new sst.Secret("R2SqlAuthToken")
const r2Sql = new sst.Linkable("R2Sql", {
properties: {
accountId: "15d29c8639fd3733b1b5486a2acfd968",
bucket: `platform-${$app.stage}-lake`,
namespace: "inference",
table: "generation",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
@ -193,7 +203,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
// Keep the legacy Athena link and IAM permissions during the first R2-backed
// release so reverting the application code remains a one-deploy rollback.
link: [database, inferenceEvent, r2Sql, r2SqlAuthToken, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,

View file

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
"x86_64-linux": "sha256-RIzpvDBR6GOfU2vjrtgP3LJ/j+gLxkMnJ9VE5Y65Pfo=",
"aarch64-linux": "sha256-seE7v8AqxWCiyxNARMiLJNB1MZv15OwEf6r21k68xqg=",
"aarch64-darwin": "sha256-rbyK+3qz9bGOxg3HlGW6yh3qeazCaxFcuPfdGbXHsIY=",
"x86_64-darwin": "sha256-chSE5WtSQ+qdbj5vTvGmr4rn9gk8eVp3pVeow4gVqYI="
}
}

View file

@ -6,7 +6,7 @@
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/opencode src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
@ -136,14 +137,16 @@
"electron"
],
"overrides": {
"@ai-sdk/anthropic": "3.0.111",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"open": "11.0.4"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@ -156,6 +159,11 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch",
"@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch",
"@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch"
}
}

View file

@ -11,7 +11,7 @@
- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there.
- For local UI changes, run the backend and app dev servers separately.
- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096`
- Backend (from `packages/opencode`): `bun run ./src/index.ts serve --port 4096`
- App (from `packages/app`): `bun dev -- --port 4444`
- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
@ -19,6 +19,21 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

View file

@ -0,0 +1,220 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.

View file

@ -0,0 +1,16 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.

View file

@ -197,7 +197,9 @@ export async function setupTimeline(
)
},
async waitForPart(partID: string) {
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
},
}
}

View file

@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}

View file

@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}

View file

@ -60,6 +60,39 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
test("keeps previous file search results visible while the next search loads", async ({ page }) => {
const searchPending = Promise.withResolvers<void>()
await setup(page, async ({ query }) => {
if (query === "file-0") return ["file-00.ts"]
if (query === "file-7") {
await searchPending.promise
return ["file-79.ts"]
}
return []
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill("file-0")
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
const nextSearch = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/find/file" && url.searchParams.get("query") === "file-7"
})
await filter.fill("file-7")
await nextSearch
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
searchPending.resolve()
await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible()
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden()
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
@ -74,7 +107,10 @@ async function readProbe(page: Page) {
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
async function setup(
page: Page,
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
) {
await mockOpenCodeServer(page, {
directory,
project: {
@ -119,6 +155,7 @@ async function setup(page: Page) {
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles,
pageMessages: () => ({ items: [] }),
})

View file

@ -1,41 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})

View file

@ -0,0 +1,132 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-expand",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
{
name: "app.ts",
path: "frontend\\app.ts",
absolute: `${directory}/frontend/app.ts`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
return [
{
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
ignored: false,
},
{
name: "README.md",
path: "README.md",
absolute: `${directory}/README.md`,
type: "file" as const,
ignored: false,
},
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
})

View file

@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})

View file

@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
@ -180,10 +181,36 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
return json(route, { data: [] })
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@ -216,7 +243,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory,
home: directory,
})
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {})
})
}

View file

@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@ -57,11 +58,15 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status")
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@ -90,7 +95,20 @@ async function mockServers(page: Page) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}
@ -104,10 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
})
}
function sse(route: Route) {
function sse(route: Route, current: boolean) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
}

View file

@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start)
await expectAppVisible(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await start.dragTo(end)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
@ -54,36 +51,44 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
const textbox = review.getByRole("textbox")
await expect(textbox).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: "proj_review_line_comment_regression",
@ -143,15 +148,22 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)
const changes = page.getByRole("tab", { name: "Changes" })
const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
const file = review.locator('[data-file="src/review.ts"]')
await expectAppVisible(file)
const trigger = file.getByRole("button", { expanded: false })
await expect(trigger).toHaveCount(1)
await trigger.click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
}

View file

@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v1",
directory,
project: {
id: projectID,

View file

@ -20,10 +20,12 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v1",
directory,
project: {
id: projectID,
@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
sessionStatus: () => sessionStatus,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
@ -64,7 +66,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
body: JSON.stringify({
branch: "review-pane-performance",
default_branch: "dev",
}),
}),
)
await page.route("**/vcs/diff**", (route) => {
@ -86,15 +91,51 @@ test("keeps the review tree and terminal sized when both panels are open", async
),
})
})
await page.route("**/pty", (route) =>
await page.route("**/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route("**/pty/pty_review_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
@ -143,6 +184,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
@ -152,6 +194,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")

View file

@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await pathBlocked
return route.fallback()
})

View file

@ -0,0 +1,141 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
protocol: "v1",
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => {
if (route.request().method() !== "PATCH") return route.fallback()
const id = new URL(route.request().url()).pathname.split("/").at(-1)
const session = sessions.find((item) => item.id === id)
const payload: unknown = route.request().postDataJSON()
if (
!session ||
!payload ||
typeof payload !== "object" ||
!("title" in payload) ||
typeof payload.title !== "string"
)
throw new Error("Invalid rename request")
session.title = payload.title
await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
for (const commit of ["Enter", "blur", "click outside"]) {
test(`saves the session heading on ${commit}`, async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
})
}
test("cancels the session heading with Escape", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Discard this title")
await input.press("Escape")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("keeps the draft when saving the session heading fails", async ({ page }) => {
await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => {
if (route.request().method() !== "PATCH") return route.fallback()
return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } })
})
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Retry this title")
await input.press("Tab")
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(input).toBeEnabled()
await expect(input).toHaveValue("Retry this title")
await expect(
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
).toBeVisible()
})
test("does not save an empty session heading", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill(" ")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("renames and closes the session tab from its context menu", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
await renamed.click({ button: "right" })
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
await expect(renamed).toBeHidden()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
})

View file

@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@ -170,6 +173,7 @@ async function mockServer(
},
) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,

View file

@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundet" },
{ locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {

View file

@ -128,7 +128,7 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
test("renders inline comments and historical diff summary overflow", async ({ page }) => {
const user = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@ -152,14 +152,10 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, {
messages: [user, assistantMessage(), nextUser, nextAssistant],
settings: { newLayoutDesigns: false },
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.getByText(/show all/i)).toBeVisible()

View file

@ -1,9 +1,8 @@
import { expect, test } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID)
expect(await timeline.transport.connections()).toHaveLength(1)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
})
@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
})
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const sentinelID = "prt_transport_heartbeat_sentinel"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
})
const before = await page.locator("[data-timeline-row]").allTextContents()
await timeline.waitForPart("prt_transport_steady")
const before = await stableTimelineRows(page)
await timeline.transport.heartbeat()
await timeline.settle()
await timeline.transport.writeRaw(": heartbeat\n\n")
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
await timeline.waitForPart(sentinelID)
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
expect(await timeline.transport.connections()).toHaveLength(1)
await expect
.poll(async () => {
const rows = await timelineRows(page)
return rows.filter((row) => before.some((item) => item.key === row.key))
})
.toEqual(before)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection()
await timeline.transport.close()
@ -77,20 +84,21 @@ test("reconnects after a clean close", async ({ page }) => {
})
test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(status("busy"))
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const timeline = await setupTimeline(page, { protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@ -112,5 +120,35 @@ test("passes through non-event fetches", async ({ page }) => {
})
expect(health).toEqual({ healthy: true })
expect(await timeline.transport.connections()).toHaveLength(1)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
async function stableTimelineRows(page: Page) {
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
let stable = 0
await expect
.poll(
async () => {
const next = await timelineRows(page)
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
previous = next
return stable
},
{ intervals: [50, 50, 100] },
)
.toBeGreaterThanOrEqual(2)
return previous!
}
function timelineRows(page: Page) {
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
key: element.getAttribute("data-timeline-key"),
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: element.textContent,
})),
)
}

View file

@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, {
directory,
@ -56,9 +57,11 @@ test("animates todo lifecycle without replaying it across session tabs", async (
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
@ -68,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()

View file

@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation"
@ -39,7 +39,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events,
eventRetry: events ? 16 : undefined,
})
// The child session resolves via /session/:id but is absent from the /session list,
// The child session resolves by ID but is absent from the session list,
// matching a subagent session that has not been loaded into the list cache yet.
await page.route(
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
body: JSON.stringify({
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
}),
)
await configurePage(page)

View file

@ -1,9 +1,12 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("keyboard navigation follows the visible tab order", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, unresolved, sessionC }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: unresolved },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
await page.keyboard.press("Control+Alt+ArrowRight")
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) {
return {
id,
@ -52,13 +83,21 @@ function session(id: string, title: string) {
}
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB]
const sessions = [sessionA, sessionB, sessionC]
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@ -66,8 +105,7 @@ async function mockServer(page: Page) {
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@ -89,7 +127,20 @@ async function mockServer(page: Page) {
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}

View file

@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty*", (route) => {
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/pty", (route) => {
await page.route("**/api/pty*", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(/ghostty-web/, async (route) => {
@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/pty", (route) => {
await page.route("**/api/pty*", (route) => {
created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
body: JSON.stringify({ location: ptyLocation(), data: next }),
})
})
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
}),
)
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo(id: string, title: string) {
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}

View file

@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)

View file

@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page)
await switchTab(page, titleB)
@ -62,6 +66,7 @@ async function readProbe(page: Page) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@ -85,26 +90,33 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
const url = new URL(route.request().url())
expect(url.searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
})
})
const connections: string[] = []
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url())
})
@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo() {
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}

View file

@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_model_selection_flow",
worktree: directory,
vcs: "git",
name: "NewProject",
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
sandboxes: [],
},
provider: () => ({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 200_000 },
},
},
},
{
id: "opencode-go",
name: "OpenCode Go",
models: {
"go-model-1": {
id: "go-model-1",
name: "Go Model 1",
cost: { input: 1, output: 1 },
limit: { context: 200_000 },
},
},
},
],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
default: { providerID: "opencode", modelID: "free-model" },
}),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
})
await page.goto("/")
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
await page.locator("[data-directory-path]").click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})

View file

@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@ -23,15 +26,14 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
@ -75,11 +77,27 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
return json(route, true)
}
if (path === "/instance/dispose" && route.request().method() === "POST") {
config.onInstanceDispose?.()
return json(route, true)
}
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
if (path === "/session/status")
return json(
route,
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
)
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (path === "/file" && config.fileList)
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
@ -126,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
@ -205,6 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"

View file

@ -247,18 +247,23 @@ export async function installSseTransport<T>(
return {
server,
async waitForConnection(input = {}) {
await page.waitForFunction(
const connection = await page.waitForFunction(
(after) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.some((connection) => connection.id > after)
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
},
input.after ?? 0,
{ timeout: input.timeout },
)
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
(connection) => connection.id > (input.after ?? 0),
)!
let result: SseConnectionRecord | undefined
try {
result = await connection.jsonValue()
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })

View file

@ -1,11 +1,12 @@
{
"name": "@opencode-ai/app",
"version": "1.18.4",
"version": "1.18.33",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
@ -19,9 +20,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
@ -31,7 +32,7 @@
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@happy-dom/global-registrator": "20.12.0",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
@ -53,7 +54,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",

View file

@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
@ -58,6 +57,7 @@ import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import { VoiceOverlay, VoiceProvider } from "@/voice"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session"))
@ -233,7 +234,37 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
)
}
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</>
}
declare global {
@ -302,7 +333,7 @@ function DesktopCommands() {
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
@ -360,7 +391,12 @@ function DraftProviders(props: ParentProps) {
)
}
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
return (
<MetaProvider>
<Font />
@ -369,24 +405,27 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
<VoiceProvider>
<ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<FileComponentProvider component={File}>
{props.children}
<VoiceOverlay />
</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</VoiceProvider>
</UiI18nBridge>
</LanguageProvider>
</ThemeProvider>

View file

@ -1,5 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = {
id: string
@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
untitled: () => string
category: () => string
}) {
@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
return props
.load(search, current.signal)
.then((result) =>
(result.data ?? [])
result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived)
.map((session) => {
const project =
@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
description: project ? displayName(project) : getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,

View file

@ -60,35 +60,44 @@ function Cell(props: {
label: string
tip: string
value: string
wide?: boolean
span?: 2 | 3
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
"col-span-2": props.span === 2 && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
"flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</div>
)
@ -108,37 +117,50 @@ function Cell(props: {
)
}
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
function ToggleCell(props: {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-label={`${props.label}: ${props.value}`}
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
<span
classList={{
flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
<Tooltip value={props.tip} placement="top">
{content()}
</Tooltip>
)
@ -457,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
"grid-cols-4": !props.inline,
grid: !props.inline,
}}
>
@ -539,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
wide={!platform.setForceFocus}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
)}
</div>
</aside>

View file

@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})

View file

@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@ -28,15 +28,19 @@ import {
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@ -154,7 +158,7 @@ function ProviderPicker(props: {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@ -226,10 +230,8 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
@ -266,19 +268,7 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => {
props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
props.onSelect(provider)
}
const move = (event: KeyboardEvent, direction: number) => {
@ -395,10 +385,17 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const params = useParams()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@ -413,38 +410,34 @@ function ProviderConnection(props: {
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
const fallback = createMemo<ConnectMethod[]>(() => [
{
type: "api" as const,
type: "key" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({
methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>,
state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
@ -454,7 +447,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
@ -508,7 +501,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey")
if (value.type === "key") return language.t("provider.connect.method.apiKey")
return value.label ?? ""
}
@ -518,7 +511,12 @@ function ProviderConnection(props: {
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
hint:
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
}
}
@ -549,46 +547,27 @@ function ProviderConnection(props: {
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
if (!alive.value) return
const elapsed = Date.now() - start
const delay = 1000 - elapsed
if (delay > 0) {
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
if (props.provider === "opencode" && platform.platform === "desktop") {
const url = new URL(x.data.url)
url.searchParams.set("client_id", "opencode-desktop")
x.data.url = url.href
}
dispatch({ type: "auth.complete", authorization: x.data! })
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
@ -603,9 +582,9 @@ function ProviderConnection(props: {
index: 0,
})
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const prompts = createMemo(() => {
const value = method()
return value?.prompts ?? []
return value?.type === "oauth" ? (value.prompts ?? []) : []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@ -636,10 +615,6 @@ function ProviderConnection(props: {
setFormStore("index", next)
return
}
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value)
}
@ -741,7 +716,9 @@ function ProviderConnection(props: {
})
async function complete() {
await serverSDK().client.global.dispose()
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
@ -805,7 +782,7 @@ function ProviderConnection(props: {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => {
if (!selected) return
void selectMethod(index)
@ -851,13 +828,10 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
})
await complete()
}
@ -874,12 +848,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@ -891,6 +865,7 @@ function ProviderConnection(props: {
ref={apiKey}
class="!w-full"
name="apiKey"
data-input="provider-api-key"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
@ -907,7 +882,7 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")}
</ButtonV2>
</form>
@ -923,9 +898,9 @@ function ProviderConnection(props: {
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@ -984,12 +959,13 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
@ -1003,9 +979,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@ -1042,7 +1018,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@ -1076,32 +1054,46 @@ function ProviderConnection(props: {
})
onMount(() => {
void (async () => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
await complete()
})()
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@ -1178,15 +1170,15 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<Match when={method()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<Match when={store.authorization?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<Match when={store.authorization?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>

View file

@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@ -184,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
</ExternalLink>
{language.t("provider.custom.description.suffix")}
</p>

View file

@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.client.session.fork({ sessionID, messageID: item.id })
.api.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)

View file

@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client"
import {
absoluteTreePath,
activeTreeNavigation,
@ -28,6 +29,7 @@ import {
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props {
title?: string
@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.client.path
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined),
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
.catch(() => [])
const results = [
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
]
return {
query: value,
@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
})
listings.set(key, request)
@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
data-directory-path={suggestion.absolute}
role="option"
aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined}

View file

@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps {
title?: string
@ -17,6 +18,8 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
@ -56,13 +59,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const missingHome = createMemo(() => !sync.data.path.home)
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
() => (missingHome() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((x) => x.data)
.then((result) => result.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@ -100,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
@ -114,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
return uniqueRows([...recentProjects(), ...directoryRows])
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
@ -162,7 +168,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
@ -174,7 +180,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
)
}
return (
<div class="w-full flex items-center justify-between rounded-md">
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">

View file

@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || toggle.isPending) return
if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name)
}}
>
@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)

View file

@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div class="flex w-full flex-col items-start pb-3">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":

View file

@ -1,15 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import {
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -29,6 +19,7 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
@ -122,14 +113,13 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
@ -174,9 +164,7 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Trigger as={props.trigger} />
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@ -237,66 +225,101 @@ export function ModelSelectorPopover(props: {
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
return (
<ModelSelectorPopoverV2View
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
}}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
)
const models = createMemo(() => {
const search = store.search.trim()
const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
})
const groups = createMemo(() => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
})
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
return {
models: (search: string) => {
const query = search.trim()
const filtered = query
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
},
groups: (models: ModelItem[]) => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
},
current: () => {
const value = model.current()
return value ? modelKey(value) : undefined
},
select: (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
input.onSelect()
},
}
}
function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => {
const selected = current()
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
}
const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => {
if (open) {
restoreTrigger = true
dismiss.allowTriggerRestore()
setStore({ open: true, active: initialActive() })
setTimeout(() =>
requestAnimationFrame(() => {
@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
}
setStore({ open: false, search: "", active: "" })
}
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => select(item))
dismiss.afterClose(() => props.select(item))
}
const manage = () => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
dismiss.afterClose(props.onManage)
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
const first = props.models(value)[0]
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Trigger as={props.trigger} />
<MenuV2.Portal>
<MenuV2.Content
ref={(el: HTMLDivElement) => (contentRef = el)}
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => props.onClose?.())
dismiss.afterClose(props.onClose)
return
}
if (event.altKey || event.metaKey) return
@ -442,10 +450,10 @@ export function ModelSelectorPopoverV2(props: {
<For each={groups()}>
{(group) => (
<MenuV2.Group>
<MenuV2.GroupLabel class="gap-2 px-3">
<MenuV2.GroupLabel class="sticky top-0 z-10 gap-2 bg-v2-background-bg-layer-01 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={current()}>
<MenuV2.RadioGroup value={props.current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
@ -465,7 +473,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {

View file

@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@ -28,7 +29,6 @@ interface ServerFormProps {
username: string
password: string
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
@ -135,7 +135,6 @@ function ServerForm(props: ServerFormProps) {
autofocus
validationState={props.error ? "invalid" : "valid"}
error={props.error}
disabled={props.busy}
onChange={props.onChange}
onKeyDown={keyDown}
/>
@ -144,8 +143,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
disabled={props.busy}
defaultValue={props.name}
onChange={props.onNameChange}
onKeyDown={keyDown}
/>
@ -154,8 +152,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
disabled={props.busy}
defaultValue={props.username}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
/>
@ -163,8 +160,7 @@ function ServerForm(props: ServerFormProps) {
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
disabled={props.busy}
defaultValue={props.password}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
/>
@ -251,18 +247,26 @@ export function useServerManagementController(options: { onSelect?: () => void;
return
}
const name = store.addServer.name.trim() || undefined
const username = store.addServer.username || undefined
const password = store.addServer.password || undefined
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
displayName: name,
http: { url: normalized, username, password },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
@ -307,6 +311,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
@ -344,7 +355,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
)
const sortedItems = createMemo(() => {
const list = items()
const raw = items()
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
@ -376,7 +390,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@ -384,12 +397,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@ -397,7 +408,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
@ -405,7 +415,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@ -413,12 +422,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@ -426,7 +433,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
@ -674,7 +680,6 @@ export function ServerConnectionForm(props: { controller: ReturnType<typeof useS
username={props.controller.formUsername()}
password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()}
error={props.controller.formError()}
status={props.controller.formStatus()}
onChange={props.controller.handleFormChange()}

View file

@ -1,4 +1,5 @@
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@ -14,10 +15,11 @@ export type DialogGoUpsellProps = {
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const runAction = () => {
if (props.link) platform.openLink(props.link)
if (props.link) platform.openExternal(props.link)
props.onClose?.()
dialog.close()
}
@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
Don't show again
{language.t("dialog.usageExceeded.dontShowAgain")}
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}

View file

@ -133,12 +133,13 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@ -152,6 +153,90 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [

View file

@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0
const scoped = (value: string) => {
const raw = normalizePickerDrive(value)
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
cache.set(key, request)
return request
@ -371,12 +374,19 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")

View file

@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return

View file

@ -0,0 +1,21 @@
import { ComponentProps, splitProps } from "solid-js"
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function ExternalLink(props: ExternalLinkProps) {
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
target={local.target ?? "_blank"}
rel={local.rel ?? "noopener noreferrer"}
{...rest}
>
{local.children}
</a>
)
}

View file

@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) {
function rowPaddingStart(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP
}
function guideLineLeft(level: number) {
return rowPaddingLeft(level, "directory") + 8
function guideLineStart(level: number) {
return rowPaddingStart(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">{local.node.name}</bdi>
</span>
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
{(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
</For>
)
}
@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,

View file

@ -146,13 +146,13 @@ const FileTreeNode = (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...local.classList,
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return

View file

@ -1,9 +1,11 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { isRTL } from "@kobalte/core/i18n"
import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useLanguage } from "@/context/language"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
@ -15,20 +17,22 @@ const showPopover = () => true
export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const language = useLanguage()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const rtl = () => isRTL(language.intl())
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label={language.t("help.tabs.toast.ariaLabel")}
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
aria-label={language.t("help.tabs.toast.dismiss")}
class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
>
<svg
@ -44,7 +48,7 @@ export function TabsInfoPopup() {
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
onClick={() => {
settings.general.dismissTabsToast()
setDrawerOpen(true)
@ -62,10 +66,10 @@ export function TabsInfoPopup() {
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
{language.t("help.tabs.title")}
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
{language.t("help.tabs.description")}
</p>
</div>
</button>
@ -75,7 +79,10 @@ export function TabsInfoPopup() {
style={
windows()
? {
inset: "0 0 0 auto",
top: "0",
bottom: "0",
"inset-inline-end": "0",
"inset-inline-start": "auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
@ -89,9 +96,9 @@ export function TabsInfoPopup() {
type="button"
size="small"
variant="neutral"
aria-label="Close"
aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
class="absolute top-[10px] start-[-36px]"
/>
</Show>
<div
@ -102,7 +109,7 @@ export function TabsInfoPopup() {
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
{language.t("help.tabs.date")}
</p>
<Show when={!windows()}>
<DrawerClose
@ -110,36 +117,24 @@ export function TabsInfoPopup() {
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
{language.t("help.tabs.title")}
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<p>{language.t("help.tabs.introduction")}</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<p>{language.t("help.tabs.sessions")}</p>
<p>{language.t("help.tabs.organize")}</p>
<p>{language.t("help.tabs.home")}</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
<p>{language.t("help.tabs.persistence")}</p>
<p>{language.t("help.tabs.worktrees")}</p>
</div>
</div>
</DrawerContent>

View file

@ -1,26 +0,0 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</a>
)
}

View file

@ -5,8 +5,8 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
@ -26,6 +26,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { VoiceButton, useVoiceOptional } from "@/voice"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
@ -37,11 +38,9 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
@ -50,9 +49,52 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
const voice = useVoiceOptional()
const prompt = usePrompt()
useCommands(props)
useEditHandler(props)
createEffect(() => {
const unregister = voice.registerActions({
insertText: (text) => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
if (editor && typeof document.execCommand === "function") {
editor.focus()
document.execCommand("insertText", false, text.endsWith(" ") ? text : `${text} `)
return
}
props.controller.onInput(text)
},
submit: () => props.controller.submit(),
clear: () => {
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
props.controller.onInput("")
},
stop: () => props.controller.stop(),
accept: () => {
document.querySelector<HTMLButtonElement>('[data-action="permission-once"]')?.click()
},
reject: () => {
document.querySelector<HTMLButtonElement>('[data-action="permission-reject"]')?.click()
},
scrollUp: () => {
const scroller = document.querySelector<HTMLElement>("[data-slot='session-scroll']")
scroller?.scrollBy({ top: -320, behavior: "smooth" })
},
scrollDown: () => {
const scroller = document.querySelector<HTMLElement>("[data-slot='session-scroll']")
scroller?.scrollBy({ top: 320, behavior: "smooth" })
},
focus: () => props.controller.restoreFocus(),
newline: () => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
if (editor && typeof document.execCommand === "function") {
editor.focus()
document.execCommand("insertText", false, "\n")
}
},
run: () => props.controller.submit(),
})
onCleanup(unregister)
})
return (
<div class="flex flex-col gap-3">
@ -60,8 +102,10 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
actions={<VoiceButton />}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
@ -82,70 +126,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
@ -205,7 +185,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const designPlaceholder = () =>
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@ -412,7 +395,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
@ -436,6 +419,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
@ -444,18 +428,20 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
},
view: {
placeholder: designPlaceholder,
agent:
props.controls.agents.visible && props.controls.agents.options.length > 0
get agent() {
return props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: props.controls.agents.select,
onSelect: (value: string) => props.controls.agents.select(value),
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined,
: undefined
},
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
@ -471,6 +457,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: controller.state.mode !== "normal",
onSelect: () => controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: controller.state.mode === "shell",
onSelect: () => controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: controller.state.mode === "normal",
onSelect: () => controller.dispatch({ type: "mode.normal" }),
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController
}
@ -520,6 +562,7 @@ function PromptInputV2ModelControl(props: {
fallback={
<ButtonV2
data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@ -533,19 +576,22 @@ function PromptInputV2ModelControl(props: {
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
}}
trigger={(triggerProps) => (
<ButtonV2
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="prompt-model"
data-control-type="popover"
>
{content()}
</ButtonV2>
)}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
/>
</Show>
</TooltipV2>
</Show>

View file

@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name,
description: cmd.description,
type: "custom" as const,
source: cmd.source,
// source: cmd.source,
}))
return [...custom, ...builtin]
@ -1489,10 +1489,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
fileLabel={language.t("ui.common.file")}
newLayoutDesigns={false}
/>
<div
@ -1723,29 +1724,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<ModelSelectorPopover
model={props.controls.model.selection}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
trigger={(triggerProps) => (
<Button
{...triggerProps}
variant="ghost"
size="normal"
style={control()}
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
data-action="prompt-model"
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
)}
onClose={restoreFocus}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
/>
</TooltipKeybind>
</Show>
</div>

View file

@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
draftStore?: DraftStore
}
export type PromptAttachmentsInput = {
@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false
}
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({
...input,
draftStore: platform.draftStore,
capture: input.prompt.capture,
warn: () => {
showToast({

View file

@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: ImageAttachmentPart[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string

View file

@ -1,7 +1,13 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
}
}

View file

@ -112,7 +112,7 @@ describe("prompt-input history", () => {
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)

View file

@ -16,6 +16,7 @@ type PromptImageAttachmentsProps = {
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
fileLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
@ -94,13 +95,13 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
}
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
</AttachmentCardV2>
</Show>
}
>
<img
src={attachment.dataUrl}
src={attachment.blob.url}
alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)}

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { promptPlaceholder } from "./placeholder"
import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
@ -46,3 +46,18 @@ describe("promptPlaceholder", () => {
expect(value).toBe("prompt.placeholder.simple")
})
})
describe("promptDesignPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => {
if (key !== "ui.promptInput.placeholder.normal") return key
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
}
test("composes the design placeholder from localized fragments", () => {
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
})
test("preserves the shell placeholder", () => {
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
})
})

View file

@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
return input.t("prompt.placeholder.normal", { example: input.example })
}
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
export function promptDesignPlaceholder(
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}

View file

@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
@ -19,11 +24,15 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sessionDirectories: Record<string, string> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@ -32,7 +41,7 @@ let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
@ -64,23 +73,39 @@ const prompt = {
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
},
}
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
},
session: {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@ -90,27 +115,6 @@ const clientFor = (directory: string) => {
}
}
const api = {
session: {
async create(input: { location: { directory: string } }) {
await createSessionGate
createdSessions.push(input.location.directory)
const session = {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
}
sessionDirectories[session.id] = input.location.directory
return session
},
async shell(input: { sessionID: string }) {
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
},
async prompt() {},
async command() {},
async interrupt() {},
},
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@ -193,8 +197,8 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@ -206,7 +210,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({
useSync: () => () => ({
data: { command: [] },
data: { command: commands },
session: {
optimistic: {
add: (value: {
@ -233,6 +237,9 @@ beforeAll(async () => {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
@ -274,11 +281,17 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
@ -287,8 +300,8 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})
describe("prompt submit worktree selection", () => {
@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
@ -443,6 +472,7 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
@ -451,6 +481,56 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
@ -511,7 +591,8 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
})
})

View file

@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
@ -95,10 +96,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
return true
} catch (err) {
@ -108,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
const messageID = input.messageID ?? Identifier.ascending("message")
const encodedImages = await Promise.all(
images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images,
images: encodedImages,
text,
sessionID: input.draft.sessionID,
messageID,
@ -162,6 +171,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
@ -515,10 +525,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })

View file

@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = {
name?: string
@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
dismiss.afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
dismiss.afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
dismiss.preventTriggerRestore()
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
onOpenChange={(open) => {
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
>
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal>
@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@ -364,41 +356,45 @@ export function PromptProjectSelector(props: {
</button>
</Show>
</div>
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
<div class="max-h-[224px] overflow-y-auto">
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
)}
</For>
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">

View file

@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
return (
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
)
}
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
return {
more: language.t("common.moreOptions"),
server: language.t("settings.section.server"),
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
}
}
export const ServerRowMenuView: Component<{
server: ServerConnection.Any
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
aria-label={props.labels.more}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
disabled={builtin() || !httpServer()}
onSelect={() => {
const server = httpServer()
if (server) props.onEdit(server)
}}
>
{language.t("dialog.server.menu.edit")}
{props.labels.edit}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>

View file

@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"

View file

@ -31,7 +31,10 @@ export function OpenInAppV2(props: { directory: () => string }) {
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Show
when={state.opening()}
fallback={<AppIcon id={state.current().icon} style={{ width: "20px", height: "20px" }} />}
>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>

View file

@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@ -120,7 +123,8 @@ export function SessionContextTab() {
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
@ -220,6 +224,31 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
sessionID,
client: sdk().client,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
@ -328,7 +357,18 @@ export function SessionContextTab() {
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<div class="flex items-center justify-between">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => (

View file

@ -289,9 +289,9 @@ export function SessionHeader() {
return (
<>
<Show when={search() && centerMount()}>
<Show when={search() && centerMount()} keyed>
{(mount) => (
<Portal mount={mount()}>
<Portal mount={mount}>
<Button
type="button"
variant="ghost"
@ -308,10 +308,10 @@ export function SessionHeader() {
</span>
</div>
<Show when={hotkey()}>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
{keybind}
</Keybind>
)}
</Show>
@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal>
)}
</Show>
<Show when={rightMount()}>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount()}>
<Portal mount={mount}>
<Show
when={isV2}
fallback={

View file

@ -1,16 +0,0 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}

View file

@ -29,7 +29,7 @@ import {
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { ExternalLink } from "./external-link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
@ -127,11 +127,14 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
@ -479,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
</>
}
>

View file

@ -30,6 +30,8 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
type LanguageContext = ReturnType<typeof useLanguage>
type SettingsContext = ReturnType<typeof useSettings>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" })
@ -262,7 +264,274 @@ function useKeyCapture(input: {
})
}
export function createKeybindSettingsController(
input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
settings: {
current: { keybinds: unknown }
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
}
target?: Document
notify?: (toast: { title: string; description: string }) => void
},
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
) {
const [store, setStore] = createStore({ active: null as string | null })
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
const list = createMemo(() => {
language.locale()
return listFor(input.command, overrides(), language.t("command.palette"))
})
const grouped = createMemo(() => groupedFor(list()))
const title = (id: string) => list().get(id)?.title ?? ""
const effective = (id: string) => {
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
const custom = input.settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = input.command.options.find((item) => item.id === id)
if (live?.keybind) return live.keybind
return input.command.catalog.find((item) => item.id === id)?.keybind
}
const used = createMemo(() => {
const value = new Map<string, { id: string; title: string }[]>()
for (const id of list().keys()) {
for (const signature of signatures(effective(id))) {
const items = value.get(signature)
if (items) {
items.push({ id, title: title(id) })
continue
}
value.set(signature, [{ id, title: title(id) }])
}
}
return value
})
const stop = () => {
if (!store.active) return
setStore("active", null)
input.command.keybinds(true)
}
const toggle = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
input.command.keybinds(false)
}
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.settings.keybinds.set(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const signature of signatures(next)) {
for (const item of used().get(signature) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
notify({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.settings.keybinds.set(id, next)
stop()
}
const target = input.target ?? (typeof document === "object" ? document : undefined)
if (target) makeEventListener(target, "keydown", handle, { capture: true })
onCleanup(() => {
if (store.active) input.command.keybinds(true)
})
return {
catalog: {
groups: GROUPS,
filtered: (query: string) =>
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
title,
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
},
capture: {
active: () => store.active,
toggle,
},
settings: {
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
reset: () => {
stop()
input.settings.keybinds.resetAll()
notify({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
},
},
}
}
function SettingsKeybindsV2() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
command,
settings,
})
return (
<SettingsKeybindsV2View
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
}
function SettingsKeybindsV2View(props: {
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: () => string | null
onCapture: (id: string) => void
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
<SettingsListV2>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsListV2>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-v2-shortcuts-status">
<span>{language.t("settings.shortcuts.search.empty")}</span>
<span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
</div>
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
)
return (
<Show
when={props.v2}
fallback={
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</Button>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
class="flex-1"
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
<div class="settings-v2-tab-body">{groups}</div>
</>
</Show>
</div>
{groups}
</div>
)
}

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