Compare commits

...

1372 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
opencode-agent[bot]
adba484df4 chore: generate 2026-07-24 02:50:38 +00:00
Brendan Allan
37c263e153
feat(app): project current server state (#38459) 2026-07-24 10:49:16 +08:00
Jack
204f48de8b
docs(zen): add Ling 3.0 Flash free model (#38503) 2026-07-24 10:22:45 +08:00
opencode-agent[bot]
743f6410f2 chore: update nix node_modules hashes 2026-07-23 18:04:46 +00:00
Aiden Cline
20589d66d5
fix(provider): preserve Mistral reasoning history (#38453) 2026-07-23 12:47:33 -05:00
opencode-agent[bot]
62e4641235 chore: generate 2026-07-23 09:23:15 +00:00
Brendan Allan
e59ba24b80
feat(app): support current event transport (#38464) 2026-07-23 17:19:44 +08:00
opencode-agent[bot]
347510a73b chore: generate 2026-07-23 08:01:01 +00:00
Brendan Allan
d03e0c5e54
feat(app): add dual-server compatibility (#38462) 2026-07-23 15:59:38 +08:00
opencode-agent[bot]
84c79c1399 chore: update nix node_modules hashes 2026-07-23 07:08:44 +00:00
Brendan Allan
e45210c6d2
chore(app): vendor v2 promise client (#38467) 2026-07-23 14:48:33 +08:00
opencode-agent[bot]
92cede0541 chore: update nix node_modules hashes 2026-07-23 04:06:08 +00:00
Aiden Cline
fada1a538f
fix(provider): serialize Mistral prompt cache keys (#38448) 2026-07-22 22:53:51 -05:00
Aiden Cline
542ba88602
fix(provider): select prompt cache keys by SDK (#38424) 2026-07-22 22:37:22 -05:00
Jack
411eff73f0
feat(go): add Hy3 to Go model lineup (#38349)
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-23 00:41:48 +08:00
Aiden Cline
50eee1f5a4
fix(provider): correct MiniMax M3 thinking variants (#38330) 2026-07-22 10:38:23 -05:00
Jack
0a601cf334
fix(docs): correct Kimi K2.7 Code request limits (#38248) 2026-07-22 12:42:44 +08:00
opencode-agent[bot]
c9db6e9a1f
fix(app): show running shell command (#38080)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-22 11:47:42 +10:00
opencode-agent[bot]
130038eb63
fix(app): defer unavailable notification state (#38186)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-22 11:16:51 +10:00
Frank
4438f69aac zen: add security check 2026-07-21 14:51:43 -04:00
opencode-agent[bot]
76ced5418f chore: generate 2026-07-21 18:43:01 +00:00
Frank
c40e3e74a7 zen: add security check 2026-07-21 14:34:00 -04:00
Frank
0317531906 zen: add laguna-2-2.1 2026-07-21 12:59:49 -04:00
opencode-agent[bot]
5f241f1cc1 chore: generate 2026-07-21 15:19:02 +00:00
Daniel Polito
b513fafe83
fix(app): support nested slash command autocomplete (#38097) 2026-07-21 23:08:23 +08:00
Daniel Polito
21c4c93770
fix(ui): improve progress circle contrast (#38101) 2026-07-21 23:08:09 +08:00
Frank
abc4b83f58 Zen: gemini 3.6 flash and 3.5 flash lite 2026-07-21 10:59:30 -04:00
opencode-agent[bot]
cb562b2c62 chore: generate 2026-07-21 09:58:21 +00:00
Aarav Sareen
2d2339d37d
feat(app): draggable project rows (#38055) 2026-07-21 17:56:42 +08:00
Aiden Cline
849c2598ab
docs: generalize session title model description (#37986) 2026-07-20 14:20:13 -05:00
Aiden Cline
3033afba51
fix(provider): normalize Mistral family tool call IDs (#37982)
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
2026-07-20 13:58:49 -05:00
Matthias Reso
5a8ee27254
fix(provider): update muse-spark reasoning default and system prompt (#37562)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-20 13:15:42 -05:00
OpeOginni
4cc022481c
fix(opencode): display proper session import errors (#36258) 2026-07-20 11:43:27 -05:00
opencode
d36a2d8981 sync release versions for v1.18.4 2026-07-20 15:28:16 +00:00
opencode-agent[bot]
96ff82a11f
fix(console): remove desktop promo overlay
Requested by: @thdxr (dax via Slack)
2026-07-20 15:28:00 +00:00
opencode-agent[bot]
4872c48c23
fix(app): complete mentions at cursor (#37941)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:53:10 +08:00
opencode-agent[bot]
47fc6f2991
fix(app): preserve mentions during paste (#37940)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:52:14 +08:00
opencode-agent[bot]
da312b009c
fix(app): preserve command menu drafts (#37942)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-20 22:49:39 +08:00
opencode-agent[bot]
70535a1bad chore: update nix node_modules hashes 2026-07-20 14:35:56 +00:00
Luke Parker
43e472bba7
feat(app): sync embedded terminal theme (#37931) 2026-07-20 14:22:26 +00:00
opencode-agent[bot]
b67fda133a chore: update nix node_modules hashes 2026-07-20 13:24:20 +00:00
Luke Parker
e2531dff0f
chore(app): bump ghostty-web (#37923)
Includes the physical-pixel rendering and native Ghostty sprite work from anomalyco/ghostty-web#3.
2026-07-20 13:09:46 +00:00
opencode-agent[bot]
cc3615b535
fix(app): tolerate missing message parents (#37918)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-20 12:20:41 +00:00
Luke Parker
5542415b64
fix(app): shrink Windows drawer close button (#37909) 2026-07-20 11:21:22 +00:00
Luke Parker
8bec40a816
fix(app): align Windows upgrade drawer (#37895) 2026-07-20 10:32:38 +00:00
opencode-agent[bot]
4a81e8392b
fix(app): gate notification server selection (#37890)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-20 09:22:18 +00:00
opencode-agent[bot]
a19b52e85b
fix(app): omit empty prompt text parts (#37577)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-20 17:09:06 +10:00
opencode-agent[bot]
9105f35966 chore: generate 2026-07-20 06:50:36 +00:00
Aarav Sareen
3ba3954aa0
feat(app): review panel improvements (#36716)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-20 06:49:23 +00:00
Aarav Sareen
3fc5af6dd1
feat(app): update settings description line height (#37759) 2026-07-20 15:57:11 +10:00
opencode-agent[bot]
5586f9675e
fix(app): restore model variant accessibility (#37857)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 05:20:56 +00:00
opencode-agent[bot]
3b719aba48 chore: generate 2026-07-20 04:55:12 +00:00
Qiping Li
20a3a2138e
feat(opencode): use adaptive thinking effort for kimi family on anthr… (#37696)
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-19 23:54:00 -05:00
Frank
cd46f22d51 sync 2026-07-19 23:55:16 -04:00
Frank
cfa134f042 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 23:30:38 -04:00
Frank
61653a20a5 sync 2026-07-19 23:30:36 -04:00
opencode-agent[bot]
2a097f3af7
fix(llm): expand context overflow patterns (#37840)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:15:21 -05:00
David Hill
ba4b8e21f4
feat(app): toggle debug tools from dev badge (#36689)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-20 12:22:48 +10:00
Rahul A Mistry
7985c2066a
fix(app): show keybind tooltips on prompt input controls (#37824) 2026-07-20 10:22:38 +10:00
opencode-agent[bot]
67caf894e0
fix(opencode): increase OpenAI header timeout (#37770)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:41:48 -05:00
opencode-agent[bot]
78587c141b
fix(app): restore question pager segments (#37575)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-19 13:00:57 +00:00
opencode-agent[bot]
f5573281ca chore: generate 2026-07-19 07:46:36 +00:00
opencode-agent[bot]
4817228f96
docs(go): update Kimi K3 request estimates (#37725)
Co-authored-by: Frank <frank@anoma.ly>
2026-07-19 03:45:27 -04:00
Frank
5a5117b36c Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 02:50:38 -04:00
Frank
6a288426b8 go: support openai route 2026-07-19 02:50:36 -04:00
usrnk1
573ac85983
feat(desktop): align new session project picker (#37461)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-19 16:15:54 +10:00
Frank
1221bbbe14 Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-19 02:08:19 -04:00
Frank
6bea0f4fa4 zen: grok 4.5 adjusted price 2026-07-19 02:08:05 -04:00
usrnk1
cc34084dfe
feat(desktop): prevent overlapping composer borders (#37490) 2026-07-19 16:01:16 +10:00
opencode-agent[bot]
b8142c7aa8 chore: update nix node_modules hashes 2026-07-18 17:54:17 +00:00
Simon Klee
ba3f966ed0
build: bump opentui to 0.4.5 (#37616) 2026-07-18 19:40:12 +02:00
Jérôme Benoit
b95fe7b2d7
fix(nix): restore desktop integration (#37197)
Co-authored-by: Test User <test@test.com>
2026-07-18 10:41:44 -05:00
Jérôme Benoit
9da9ed9de6
fix(nix): relax Bun version check for desktop build (#36767)
Co-authored-by: Test User <test@test.com>
2026-07-18 10:41:15 -05:00
opencode-agent[bot]
fab2133129
chore: configure trust center domain (#36910)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-18 02:51:31 -04:00
opencode-agent[bot]
901c9e7329
fix(app): disable unavailable file navigation (#37595)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-18 15:39:15 +10:00
opencode-agent[bot]
3476e6baa5 chore: update nix node_modules hashes 2026-07-18 03:05:17 +00:00
opencode-agent[bot]
86e04d4174
revert(tui): downgrade opentui to 0.4.3 (#37582)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 21:51:48 -05:00
Frank
45cd8d7692 update zen models 2026-07-17 17:10:37 -04:00
Frank
a46374e90e go: kimi k3 promotion 2026-07-17 15:58:26 -04:00
opencode-agent[bot]
41405c9eb0 chore: generate 2026-07-17 19:25:13 +00:00
Aiden Cline
49d2dd8a38
fix(provider): honor reasoning option semantics (#37519) 2026-07-17 14:23:33 -05:00
opencode-agent[bot]
0df2f6245a chore: update nix node_modules hashes 2026-07-17 17:37:59 +00:00
Sebastian
38989c57a0
chore(tui): upgrade opentui to 0.4.5 (#37522) 2026-07-17 19:14:31 +02:00
Adam
69a80663a2
fix(stats): polish comparison chart 2026-07-17 10:53:00 -05:00
opencode-agent[bot]
518b0bf9e3 chore: generate 2026-07-17 15:17:47 +00:00
Jack
be08207a88
docs(go): update DeepSeek and MiMo pricing (#37509) 2026-07-17 23:15:48 +08:00
Brendan Allan
efb6cc2d4b
fix(session-ui): preserve prompt editing behavior (#37483) 2026-07-17 19:56:07 +08:00
opencode-agent[bot]
4bffbb655f chore: update nix node_modules hashes 2026-07-17 10:15:33 +00:00
Brendan Allan
ba6cf38607
feat(desktop): add focus debug toggle (#37465) 2026-07-17 18:09:30 +08:00
Aarav Sareen
86978e7a8c
feat(ui): updates to design system (#37471) 2026-07-17 18:09:15 +08:00
opencode-agent[bot]
82a3270cf2 chore: generate 2026-07-17 10:03:42 +00:00
Brendan Allan
c0a258b22a
feat(session-ui): rewrite v2 prompt input (#37102) 2026-07-17 18:02:19 +08:00
Aarav Sareen
ed926be253
feat(app): review panel reactivity improvements (#37089) 2026-07-17 17:40:42 +08:00
Aarav Sareen
b527f605d9
feat(app): update review panel tooltip to v2 (#37095) 2026-07-17 17:38:47 +08:00
opencode-agent[bot]
aca52ba198 chore: generate 2026-07-17 08:55:53 +00:00
usrnk1
f7d5a1cbc7
feat(desktop): align provider onboarding dialogs (#36733)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-17 16:54:20 +08:00
Frank
3238daa851 Revert "chore: generate"
This reverts commit 90291fc6df.

Revert "go: kimi k3 2x usage"

This reverts commit a414e06d0c.
2026-07-17 03:38:28 -04:00
opencode-agent[bot]
90291fc6df chore: generate 2026-07-17 07:28:40 +00:00
Frank
a414e06d0c go: kimi k3 2x usage 2026-07-17 03:26:46 -04:00
opencode-agent[bot]
52f65f3a0b
feat(console): promote Kimi K3 usage limits (#37442)
Co-authored-by: Frank <frank@anoma.ly>
2026-07-17 02:24:51 -04:00
Luke Parker
3a1c6df9e2
fix(app): deduplicate diff summaries linearly (#37414) 2026-07-17 02:06:38 +00:00
Luke Parker
4436678f7d
fix(desktop): guard destroyed recovery windows (#37406) 2026-07-17 11:22:18 +10:00
opencode-agent[bot]
08fb473735 chore: generate 2026-07-16 23:21:52 +00:00
Frank
51213520f5 go: grok 4.5 and kimi k3 2026-07-16 19:20:17 -04:00
Frank
0bc9a28b5e zen: cost multiplier 2026-07-16 17:35:55 -04:00
opencode-agent[bot]
1d2a7b4c86
fix(console): persist desktop promo dismissal (#37121)
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
2026-07-16 22:17:41 +02:00
Adam
453b61e27b
fix(stats): divide comparison sections 2026-07-16 13:03:42 -05:00
Aiden Cline
95ebf50ace
fix(provider): restore Azure Cognitive Services endpoints (#37340) 2026-07-16 12:58:02 -05:00
Adam
d8a1b932eb
fix(stats): remove page gutter 2026-07-16 12:52:30 -05:00
Adam
0c91e6a620
fix(stats): refine comparison rendering 2026-07-16 12:29:12 -05:00
opencode
ef3b673084 sync release versions for v1.18.3 2026-07-16 15:34:31 +00:00
opencode-agent[bot]
c69abee0c7 chore: generate 2026-07-16 09:53:45 +00:00
Aarav Sareen
dc3ff4b7aa
fix(app): home page scroll target (#36664)
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-16 09:51:42 +00:00
Aarav Sareen
958e08e109
feat(app): cmd+k menu shows sessions on home page (#36686)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-16 09:51:20 +00:00
Brendan Allan
590a11ed98
fix(desktop): include wslServers loading in ready state (#37218) 2026-07-16 17:40:04 +08:00
opencode-agent[bot]
17544802c3
fix(app): remove inactive help button (#37217)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-16 11:40:48 +08:00
Brendan Allan
4a760b5743
fix(app): show selector for custom agents (#37198)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-07-16 08:44:47 +08:00
opencode-agent[bot]
888c4cb504
feat(tui): exit subagent menu with up arrow (#36951)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-15 16:00:53 -05:00
opencode
4394b324c9 sync release versions for v1.18.2 2026-07-15 16:15:34 +00:00
opencode-agent[bot]
a27ffb20f0
docs: document subagent depth (#37132)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-15 10:00:59 -05:00
opencode-agent[bot]
81141dc562 chore: generate 2026-07-15 14:58:09 +00:00
opencode-agent[bot]
285d315b4e
fix(core): limit subagent nesting depth (#37124)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-15 09:53:52 -05:00
BB84
656f299017
fix(core): tolerate AlreadyExists in FSUtil.ensureDir (#36542)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-15 09:26:50 -05:00
opencode-agent[bot]
04bdf7732b
fix(app): show help button in release builds (#37098)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-15 19:17:38 +08:00
opencode-agent[bot]
ab7dfb1edc
feat(app): open new tabs with mod+n (#37079)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-15 10:18:34 +00:00
Brendan Allan
05c3e40a4e
fix(app): tolerate null session archive times (#36999) 2026-07-15 03:02:49 +00:00
opencode-agent[bot]
b6478dcebf
fix(opencode): default Meta reasoning to xhigh (#36976)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-14 19:52:04 -05:00
Luke Parker
571e7b852f
fix(app): hide drawer close button on Windows (#36952) 2026-07-15 08:23:06 +10:00
opencode
888dd26e1d sync release versions for v1.18.1 2026-07-14 21:37:51 +00:00
opencode-agent[bot]
279c8bdd7f
fix(console): compress desktop promo video (#36945)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-14 17:35:40 -04:00
opencode-agent[bot]
865e4d5c86 chore: generate 2026-07-14 21:09:01 +00:00
David Hill
64950cd358
feat(console): promote desktop tabs launch (#35972) 2026-07-14 21:07:23 +00:00
Luke Parker
36157938a8
fix(app): preserve themed diff backgrounds (#36931) 2026-07-15 07:00:22 +10:00
opencode-agent[bot]
c0dea334fb
fix(app): space model provider sections (#36935)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-15 06:59:34 +10:00
Luke Parker
9b49daf3a9
fix(app): update tabs drawer copy (#36937) 2026-07-15 06:57:38 +10:00
opencode
d6f1b08ce8 sync release versions for v1.18.0 2026-07-14 20:29:46 +00:00
opencode-agent[bot]
75cf4cc8a8 chore: generate 2026-07-14 19:58:26 +00:00
Brendan Allan
4a181c3574
desktop v2 migration finalising (#36912) 2026-07-15 03:56:47 +08:00
opencode-agent[bot]
bfddf05782
fix(app): use v2 base background for file views (#36836)
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
2026-07-14 17:27:54 +00:00
opencode-agent[bot]
cdee625a13
fix: preserve team login casing (#35981)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-14 17:10:11 +00:00
Luke Parker
5f7091ab4e
fix(app): wait for connected project picker anchor (#36879) 2026-07-15 01:36:45 +10:00
Luke Parker
e7e7a97648
fix(app): retain permission state per server (#36873) 2026-07-14 15:26:58 +00:00
Luke Parker
f58b8cb67b
fix(app): avoid project picker positioning crash (#36864) 2026-07-15 01:05:02 +10:00
opencode-agent[bot]
775f687ca9 chore: generate 2026-07-14 14:52:00 +00:00
usrnk1
265a93927d
feat(desktop): add layout transition switch (#36667)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-14 14:50:20 +00:00
Aarav Sareen
3a938bb6d8
feat(app): update new session page logo (#36700) 2026-07-14 23:29:32 +10:00
opencode-agent[bot]
6f9a4e70da chore: generate 2026-07-14 13:14:16 +00:00
usrnk1
dedb4133dc
feat(desktop): improve file comments in session timeline (#36845)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-14 23:12:46 +10:00
Luke Parker
21f8184c58
fix(app): suppress review sidebar hydration motion (#36847) 2026-07-14 22:50:16 +10:00
Aarav Sareen
59fe350972
fix(app): terminal tab rename focus (#36695) 2026-07-14 22:39:56 +10:00
Aarav Sareen
5bdfcaac3c
fix(app): settings/help button misalignment in projects sidebar (#36662) 2026-07-14 22:22:52 +10:00
Aarav Sareen
323d687173
feat(app): align custom agent selector with v2 (#36832) 2026-07-14 22:10:42 +10:00
Luke Parker
cdca203be2
fix(app): enable remote session auto-accept (#36777) 2026-07-14 21:59:02 +10:00
opencode-agent[bot]
77a31d2b6e chore: generate 2026-07-14 11:51:22 +00:00
Luke Parker
a1ed99278a
fix(app): prevent terminal mount from stealing focus (#36576) 2026-07-14 21:49:57 +10:00
Luke Parker
729bd438e4
fix(app): preload more timeline messages (#36172) 2026-07-14 21:48:38 +10:00
Luke Parker
7304bb2751
fix(app): resync timeline after route reconnect (#36643) 2026-07-14 21:47:33 +10:00
Adam
456521f689
fix(data): align comparison suggestions 2026-07-14 05:44:45 -05:00
Adam
ee69079bb4
fix(data): proxy comparison sitemap 2026-07-14 05:30:13 -05:00
Adam
de14bb2e0f
fix(data): improve comparison metadata 2026-07-14 05:20:53 -05:00
Adam
0539d56fc4
fix(data): polish comparison details 2026-07-14 05:09:15 -05:00
Adam
729adde022
fix(data): show all comparison models 2026-07-14 04:58:27 -05:00
opencode-agent[bot]
b7111d5c62 chore: generate 2026-07-14 09:45:09 +00:00
Adam
1669713fbc
fix(data): restore breadcrumb styling 2026-07-14 04:43:23 -05:00
opencode-agent[bot]
cb8be9ba12 chore: generate 2026-07-14 03:38:03 +00:00
David Hill
15046658ba
feat(app): update tabs intro content (#36701)
Co-authored-by: Jay <air@live.ca>
2026-07-14 11:36:22 +08:00
opencode-agent[bot]
7255ce9a57 chore: update nix node_modules hashes 2026-07-14 00:23:00 +00:00
opencode-agent[bot]
2b9cf5d388 chore: generate 2026-07-14 00:05:56 +00:00
Luke Parker
a625d35f7b
fix(app): preserve composer caret after requests (#36503) 2026-07-14 10:04:11 +10:00
Luke Parker
449c64928b
fix(app): 78x faster Home cold loading (#36214) 2026-07-14 10:03:51 +10:00
Luke Parker
35c88c3fc8
fix(app): preserve timeline bottom anchoring (#36160) 2026-07-14 10:03:18 +10:00
Luke Parker
b5e09024d5
fix(app): clarify status indicator severity (#36031) 2026-07-14 10:02:46 +10:00
Luke Parker
9a51765bd2
fix(ui): preserve code spans adjacent to tildes (#35835) 2026-07-14 10:02:30 +10:00
opencode
bb31c9b92c sync release versions for v1.17.20 2026-07-13 21:09:53 +00:00
Aiden Cline
5117679148
fix(opencode): remove obsolete Luna Responses Lite workaround (#36750) 2026-07-13 15:29:41 -05:00
opencode-agent[bot]
0d5798c0bf chore: update nix node_modules hashes 2026-07-13 19:05:41 +00:00
Vladimir Glafirov
808677a612
chore: bump gitlab-ai-provider to 6.11.1 (#36722) 2026-07-13 13:47:42 -05:00
opencode-agent[bot]
fe41e9d482 chore: update nix node_modules hashes 2026-07-13 18:16:33 +00:00
opencode-agent[bot]
0768c4655e chore: generate 2026-07-13 18:01:24 +00:00
Adam
1586a26594
fix(data): selects 2026-07-13 12:59:37 -05:00
Adam
f026c1422c
fix(data): select component ux 2026-07-13 12:59:37 -05:00
opencode-agent[bot]
b7fdb595df chore: update nix node_modules hashes 2026-07-13 17:27:30 +00:00
Aiden Cline
049ee1ce5e
chore(provider): bump Azure AI SDK for GPT-5.6 (#36704) 2026-07-13 12:10:27 -05:00
opencode
e71fbb6d48 sync release versions for v1.17.19 2026-07-13 16:34:54 +00:00
opencode-agent[bot]
c9976d69c0 chore: update nix node_modules hashes 2026-07-13 16:22:51 +00:00
opencode-agent[bot]
27734409e4 chore: generate 2026-07-13 16:06:35 +00:00
Aiden Cline
e434ce01d3
fix(provider): support OpenAI pro reasoning mode (#36694) 2026-07-13 11:04:46 -05:00
opencode-agent[bot]
c2f93ae2ad chore: generate 2026-07-13 15:55:09 +00:00
Adam
92cc8f553a
fix(stats): correct breadcrumb behavior 2026-07-13 10:53:11 -05:00
Mark
49d997aec3
fix(xai): default store to false for Responses (#36629)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-13 10:37:12 -05:00
Aiden Cline
67aa9cee23
fix(opencode): support Luna Responses Lite over OAuth (#36685)
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
2026-07-13 10:15:06 -05:00
opencode-agent[bot]
7ec3d67829 chore: update nix node_modules hashes 2026-07-13 14:50:37 +00:00
Vladimir Glafirov
5401ebaede
chore: bump gitlab-ai-provider to 6.11.0 (#36668) 2026-07-13 09:31:20 -05:00
Aiden Cline
99668cfdce
fix(provider): tweak reasoning option driven max budget variants (#36626) 2026-07-13 09:18:32 -05:00
opencode-agent[bot]
b3a012cbdd
fix(cli): switch org after console logout (#36276)
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-13 10:59:16 +00:00
usrnk1
51b9c726cf
fix(app): remove interface transition changes accidentally merged into dev (#36653) 2026-07-13 10:38:30 +00:00
Aarav Sareen
b4e49d5b32
feat(app): redesign attachment cards (#35945)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-13 10:04:45 +00:00
opencode-agent[bot]
2b29854589 chore: generate 2026-07-13 10:04:14 +00:00
usrnk1
8ce5038bd8 Merge branch 'dev' of https://github.com/anomalyco/opencode into interface-toggle 2026-07-13 12:02:25 +02:00
usrnk1
446510a6a9 tui: surface a dismissible new-interface notice and tighten settings copy for clarity
Users who were migrated to the new interface will now see a dismissible notice in Settings explaining the old interface was phased out, so they are not surprised by the change. The new-interface toggle is also visually elevated with a card style so it is easier to find. Settings labels and descriptions across General have been rewritten to be shorter and more direct, so it is quicker to understand what each option controls. The "New" badge color is also adjusted for better readability.
2026-07-13 12:01:19 +02:00
opencode-agent[bot]
49028b6245 chore: generate 2026-07-13 09:33:54 +00:00
Aarav Sareen
d595c7e265
feat(app): review panel updates (#36240)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-13 09:32:25 +00:00
Aarav Sareen
17cd4a8c32
feat(app): align edit project modal with v2 style (#36213)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-13 09:26:53 +00:00
opencode-agent[bot]
9bbf7918de chore: generate 2026-07-13 09:25:14 +00:00
Aarav Sareen
97f502e79a
feat(app): middle click to open new tab (#36215) 2026-07-13 17:23:55 +08:00
usrnk1
ac5e249083 feat(app): add interface transition setting 2026-07-13 10:45:57 +02:00
usrnk1
140e796b21 feat(ui): add accent badge variant 2026-07-13 10:45:57 +02:00
usrnk1
dd02cea9e7
feat(desktop): fix clipped labels and branch tooltip (#35724)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-13 08:02:38 +00:00
Aiden Cline
a8062ea314
fix(provider): derive variants from reasoning metadata (#36624) 2026-07-13 01:21:59 -05:00
Aiden Cline
f47684787a
fix(opencode): filter unsupported GPT-5.6 OAuth alias (#36621) 2026-07-13 00:23:28 -05:00
Aiden Cline
8168f0f0f6
fix(provider): route gateway variants by api id (#36614) 2026-07-13 00:08:08 -05:00
opencode-agent[bot]
8036440693
ci: remove starptech from core triage assignees (#36618)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-12 23:41:02 -05:00
opencode-agent[bot]
cf7503687a chore: generate 2026-07-12 21:13:21 +00:00
Aiden Cline
6f8e1dda15
fix(provider): derive variants from reasoning options (#36543) 2026-07-12 16:12:07 -05:00
opencode-agent[bot]
4dcfd9182c chore: generate 2026-07-12 19:42:58 +00:00
Nabs
d7c0db8cee
fix(openai): use codex context limits for gpt-5.6 (#36248)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 14:41:51 -05:00
Aiden Cline
184da0e42e
test(opencode): refresh stale model references (#36546) 2026-07-12 13:28:36 -05:00
Aiden Cline
a244d82aba
test(opencode): refresh models.dev fixture (#36541) 2026-07-12 12:36:20 -05:00
opencode-agent[bot]
34e5809059
feat(tui): show idle session directory (#36457)
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-11 20:31:57 +00:00
opencode-agent[bot]
2db96c9b7e
fix(core): disable unused fff content caches (#36453)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 19:12:58 +00:00
Frank
9976269ab1 docs: publish GPT 5.6 Sol, Terra, and Luna model availability and pricing across all language docs 2026-07-10 14:13:46 -04:00
Adam
5df822be11
fix(stats): polish comparison details 2026-07-10 12:42:04 -05:00
Adam
233f069070
feat(stats): add comparison seo pages 2026-07-10 12:09:56 -05:00
Adam
4a1982f5c9
feat(stats): add model comparison radar 2026-07-10 11:41:01 -05:00
Adam
1c7f65f105
fix(stats): stop comparison overscroll 2026-07-10 07:31:00 -05:00
Adam
a84151e482
fix(stats): restore sticky comparison header 2026-07-10 07:30:00 -05:00
Adam
0340a4ff77
fix(stats): restore native trackpad scrolling 2026-07-10 07:09:25 -05:00
Adam
f4faacbe87
feat(stats): unify comparison cards 2026-07-10 06:49:56 -05:00
opencode-agent[bot]
7dbfd0b934 chore: generate 2026-07-10 11:38:07 +00:00
Adam
08a2a623dc
fix(data): cut athena stats sync cost
Compute all stat dimensions and both grains in one GROUPING SETS query
so each sync pass scans the source table once instead of eight times.
Hourly passes now only recompute the current ISO week; a daily full pass
refreshes the whole display window. Restarted daemons resume the hourly
cadence from the last completed sync instead of immediately re-running a
pass, and the workgroup kills any query scanning more than 2 TB.
2026-07-10 06:36:26 -05:00
Adam
60c88336b9
feat(stats): update model comparison page 2026-07-10 05:53:31 -05:00
Brendan Allan
8a03fc265b
feat(app): extract prompt state and add composer model selection (#36227) 2026-07-10 15:49:05 +08:00
Frank
d0ba538924 zen: track openai cache_write_tokens 2026-07-10 01:48:51 -04:00
Aarav Sareen
772e976d80
fix(app): timeline width jumps around when review panel is open/closed (#36202) 2026-07-10 03:59:03 +00:00
Aarav Sareen
c769380776
feat(app): visual improvements (#36073) 2026-07-10 10:52:05 +08:00
opencode-agent[bot]
01131c75d8
fix(app): focus input on new session (#36077)
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-10 10:41:54 +08:00
Brendan Allan
ea982c3832
fix(app): wrap session creation state updates in startTransition (#36182) 2026-07-10 02:29:30 +00:00
opencode-agent[bot]
d09d5612f3 chore: generate 2026-07-10 00:55:59 +00:00
Adam
85ef35dd64
fix(data): oom crash in stats sync 2026-07-09 19:53:56 -05:00
opencode-agent[bot]
251177d56c
fix(tui): forward environment to worker (#35925)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-09 18:30:03 -05:00
opencode-agent[bot]
13b9e55c72
fix(ui): sometimes outlines get clipped (#33846)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-10 09:13:34 +10:00
Luke Parker
b94e55c7c0
fix(app): align context tokens with usage (#36015) 2026-07-10 09:12:54 +10:00
Luke Parker
9c6bb4ff72
fix(app): show tree while opening files (#36018) 2026-07-10 09:12:20 +10:00
opencode
c2e019ba7d sync release versions for v1.17.18 2026-07-09 18:51:42 +00:00
Aiden Cline
b8374b5a7c
fix(copilot): handle zero billing batch size (#36123) 2026-07-09 13:14:33 -05:00
Aiden Cline
b4665a8bf8
feat: add meta muse system prompt (#36122) 2026-07-09 13:00:39 -05:00
opencode
08096b5e61 sync release versions for v1.17.17 2026-07-09 15:03:10 +00:00
Aiden Cline
2e43d41af0
tweak: adjust variant logic for muse (#36079) 2026-07-09 09:07:02 -05:00
Frank
518772c2ba Merge branch 'dev' of github.com:anomalyco/opencode into dev 2026-07-09 08:03:18 -04:00
Frank
fc9021ccec zen: retry on 529 2026-07-09 08:03:16 -04:00
opencode-agent[bot]
3bebfe6b1b chore: update nix node_modules hashes 2026-07-09 11:41:13 +00:00
opencode-agent[bot]
3b18c64782 chore: generate 2026-07-09 11:26:18 +00:00
Brendan Allan
c6c599b872
feat(app): refactor help button and add tabs info popup persistence (#36066)
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
2026-07-09 11:24:39 +00:00
opencode-agent[bot]
6b41ae910c chore: generate 2026-07-09 08:08:24 +00:00
usrnk1
5c860d4142
feat(feat): align sub-agent task rows with v2 design (#35167) 2026-07-09 16:07:02 +08:00
usrnk1
4f9207daac
feat(app): restyle revert dock for v2 (#35560) 2026-07-09 16:06:00 +08:00
Aarav Sareen
ae7d63272c
feat(app): fix descender clipping (#35950) 2026-07-09 15:09:04 +08:00
Aarav Sareen
5cc3a51357
feat(app): free model selector (#35707)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-09 07:05:09 +00:00
opencode
e357c90212 sync release versions for v1.17.16 2026-07-09 06:35:57 +00:00
opencode-agent[bot]
fb1edc4918 chore: update nix node_modules hashes 2026-07-09 05:48:48 +00:00
opencode-agent[bot]
353e0eef8a chore: generate 2026-07-09 05:43:07 +00:00
usrnk1
a4c17f08b1
feat(desktop): reveal projects in file manager (#35731) 2026-07-09 13:41:56 +08:00
usrnk1
3cd9ee5a73
feat(app): add composer add menu with draft-preserving commands (#35711) 2026-07-09 13:39:55 +08:00
opencode-agent[bot]
7c56319aa4 chore: generate 2026-07-09 05:28:34 +00:00
usrnk1
13fa4937b2
feat(desktop): add provider connection tip (#35948)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-09 13:27:22 +08:00
Aiden Cline
1db5c2402c
fix(opencode): expose Grok reasoning variants (#36017) 2026-07-09 00:13:10 -05:00
opencode-agent[bot]
0abbcddac2
chore: add missing team members (#35980)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-09 09:53:05 +10:00
Luke Parker
dd25d143c5
fix(app): reserve review pane minimum instead of capping chat width at 45% (#35078) 2026-07-09 09:47:46 +10:00
Luke Parker
d8a57ee15c
fix(app): unmount hidden session panes (#35628) 2026-07-09 09:44:02 +10:00
Luke Parker
aa52d30d7f
feat(app): persist review state per session (#35488) 2026-07-08 23:43:28 +00:00
Luke Parker
83d633fd13
fix(app): route legacy drafts to session page (#35808) 2026-07-09 07:38:56 +08:00
Luke Parker
824455e2f1
fix(app): load capped review patches (#35633) 2026-07-09 09:09:32 +10:00
Luke Parker
62de2b3695
feat(app): add inline file browser tabs (#35829) 2026-07-09 09:09:21 +10:00
opencode-agent[bot]
7550aa4b89 chore: update nix node_modules hashes 2026-07-08 22:51:45 +00:00
opencode-agent[bot]
c33a2011c4
fix(app): prevent command palette first-open flash (#35858) 2026-07-09 06:51:04 +08:00
David Hill
fff6764ed4
fix(console): update square avatar previews (#35975) 2026-07-08 22:48:11 +00:00
opencode-agent[bot]
9644a008a4 chore: generate 2026-07-08 22:31:09 +00:00
Aiden Cline
ccb6b7c3ea
fix: improve xai cache hit rate (#35970) 2026-07-08 17:29:36 -05:00
opencode-agent[bot]
f95f877e5f
fix(core): watch only git projects (#35957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-08 20:58:41 +00:00
Frank
fe96d4aebd zen: grok 4.5 2026-07-08 15:52:56 -04:00
Frank
3a95d56144 Merge branch 'dev' of https://github.com/anomalyco/opencode into dev 2026-07-08 15:30:52 -04:00
Frank
ecd8be7593 zen: grok 4.5 2026-07-08 15:30:49 -04:00
opencode-agent[bot]
a72c3880bb chore: generate 2026-07-08 16:30:17 +00:00
Adam
ccd8676bee
feat(stats): add model comparison home 2026-07-08 11:28:28 -05:00
Adam
5a533e366f
feat(stats): add model comparison pages 2026-07-08 11:28:27 -05:00
Luke Parker
77429f5982
feat(app): sync product translations (#35856) 2026-07-08 14:57:28 +10:00
Brendan Allan
6cfe4bc34f
fix(app): route new session shortcut through tabs (#35849) 2026-07-08 11:44:56 +08:00
Brendan Allan
95013d2936
fix(app): keep session routes within layouts (#35842) 2026-07-08 03:24:21 +00:00
Adam
14a5529793
feat(data): redesign model peers 2026-07-07 11:15:43 -05:00
opencode
0b2e8707e3 sync release versions for v1.17.15 2026-07-07 15:31:36 +00:00
冯基魁
adf178a6b9
fix(llm): classify zai token limit overflow (#35671) 2026-07-07 09:46:27 -05:00
opencode-agent[bot]
78f85b1cd6
fix(core): ensure relevant files survive compaction (#35636)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-07 09:39:01 -05:00
opencode-agent[bot]
888dc67cc3 chore: generate 2026-07-07 14:25:21 +00:00
Dustin Deus
fd9ee435ec
fix(core): expand home-relative permission paths (#35737) 2026-07-07 16:23:50 +02:00
opencode-agent[bot]
1c25b2f298 chore: generate 2026-07-07 11:55:25 +00:00
Adam
ea9fffce2e
feat(data): redesign model efficiency 2026-07-07 06:53:36 -05:00
Brendan Allan
516f0266b8
fix(app): hide markdown task markers (#35670) 2026-07-07 15:22:49 +08:00
opencode-agent[bot]
39397deb8d chore: generate 2026-07-07 06:44:40 +00:00
Brendan Allan
65fd2e5c91
fix(app): prioritize shortcuts in terminal (#35668) 2026-07-07 06:43:30 +00:00
opencode-agent[bot]
c5fe32fbb1
fix(app): show unread for pending questions (#34684)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 13:27:19 +08:00
opencode-agent[bot]
c48b77d6f9 chore: generate 2026-07-07 05:12:15 +00:00
Brendan Allan
64fd7a2027
fix(app): persist session tab info (#34934) 2026-07-07 05:11:08 +00:00
opencode-agent[bot]
ae259d87f0
fix(app): apply settings themes on select (#35011)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-07 13:03:25 +08:00
Aarav Sareen
50374abab8
feat(app): change traffic light position, fix sequoia bug (#35081) 2026-07-07 12:49:28 +08:00
opencode-agent[bot]
0497e8badf chore: generate 2026-07-07 04:46:52 +00:00
Aarav Sareen
2a08f8f5f4
feat(app): reintroduce model context tooltip in model selector (#35087) 2026-07-07 12:45:42 +08:00
opencode-agent[bot]
9353559088 chore: generate 2026-07-07 02:37:53 +00:00
Aarav Sareen
6aff2ee169
feat(app): visual improvements (#35134) 2026-07-07 10:36:27 +08:00
James Long
254a481e5d
fix(config): handle unavailable config directories (#35632)
Co-authored-by: James Long <jlongster@users.noreply.github.com>
2026-07-06 22:11:13 -04:00
Aarav Sareen
26885e7118
feat(app): align cmd k menu with v2 styles (#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 08:52:57 +08:00
opencode-agent[bot]
dba08013d1 chore: generate 2026-07-06 20:10:55 +00:00
Adam
fc09646973
fix(data): refine model trend charts 2026-07-06 15:03:07 -05:00
Adam
c3b9c9d888
feat(data): improved model usage section 2026-07-06 15:03:07 -05:00
opencode
203885d241 sync release versions for v1.17.14 2026-07-06 18:50:51 +00:00
opencode-agent[bot]
728e86b287 chore: generate 2026-07-06 18:20:49 +00:00
Aiden Cline
d5aa79c73a
feat(codemode): sync v2 implementation (#35574) 2026-07-06 13:19:21 -05:00
opencode-agent[bot]
eb6ff0c1e0 chore: update nix node_modules hashes 2026-07-06 13:35:10 +00:00
opencode-agent[bot]
0df44c4e91 chore: generate 2026-07-06 11:02:19 +00:00
Simon Klee
561070fbc2
deps: upgrade OpenTUI to v0.4.3 (#35226) 2026-07-06 13:01:08 +02:00
Brendan Allan
b0e41ff2c4
fix(app): use selected home project for new sessions (#35530) 2026-07-06 16:37:50 +08:00
opencode-agent[bot]
977a40af68 chore: generate 2026-07-06 08:21:25 +00:00
Brendan Allan
dffecb6478
fix(desktop): gate first launch onboarding (#34930) 2026-07-06 16:20:15 +08:00
Brendan Allan
7f57d2a9ac
feat(app): show draft server status in titlebar (#35521) 2026-07-06 16:12:53 +08:00
opencode-agent[bot]
377d5d2287
fix(app): avoid shortcut settings flash (#35349)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-06 16:12:30 +08:00
Brendan Allan
38bb38ecb2
refactor(app): unify provider connect dialog (#35518) 2026-07-06 07:50:08 +00:00
Luke Parker
3a149ba71c
fix(app): optimize large review panes (#35375) 2026-07-06 16:26:03 +10:00
opencode-agent[bot]
14df88eab5
fix(app): preserve provider dialog backdrop (#35370)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-06 06:08:59 +00:00
opencode-agent[bot]
e12cb7fb6b chore: generate 2026-07-06 05:53:41 +00:00
Aiden Cline
d4f7039932
fix(codemode): unify catalog signatures (#35452) 2026-07-06 00:52:37 -05:00
opencode-agent[bot]
e0ec9be238 chore: update nix node_modules hashes 2026-07-06 03:49:25 +00:00
Aiden Cline
2b34df94fa
fix(mcp): preserve metadata across tool pages (#35439) 2026-07-05 22:28:28 -05:00
opencode-agent[bot]
68f225a11d
fix(provider): preserve OpenRouter small model effort (#35478)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-05 18:50:25 -05:00
David Hill
e9f5d34096
fix(tui): shorten home tips (#31966) 2026-07-05 14:44:10 -05:00
Aiden Cline
d3459eb740
test(mcp): replace module mocks with real servers (#35450) 2026-07-05 12:49:31 -05:00
Aiden Cline
be73f465df
refactor(codemode): namespace public types (#35425) 2026-07-05 10:34:58 -05:00
Aiden Cline
f14eafe9db
refactor(codemode): remove generic agent tool (#35417) 2026-07-05 09:30:46 -05:00
Jack
b7e4f1ef74
fix: update Feishu community link (#35392) 2026-07-05 14:00:14 +08:00
Luke Parker
efd5f0a80f
fix(app): preserve timeline selection autoscroll (#35383) 2026-07-05 02:47:37 +00:00
opencode-agent[bot]
7135bc4fb5 chore: generate 2026-07-05 02:02:39 +00:00
Aarav Sareen
7cf8d1ca6d
feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-05 02:01:30 +00:00
opencode-agent[bot]
78bca5c707 chore: generate 2026-07-05 01:39:09 +00:00
usrnk1
82db86e2a1
feat(desktop): reopen closed tabs and background tab open (#35010)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-05 01:37:59 +00:00
opencode-agent[bot]
476e3fc382 chore: generate 2026-07-05 00:51:28 +00:00
Luke Parker
a12d50e15a
fix(app): hydrate timeline message parents (#35269) 2026-07-05 10:49:46 +10:00
opencode-agent[bot]
1b9b260458 chore: generate 2026-07-04 21:18:39 +00:00
Aiden Cline
a8983bd2c7
feat(codemode): add OpenAPI tool adapter (#35192) 2026-07-04 16:17:30 -05:00
opencode-agent[bot]
709af58612
fix(core): stop after declined permissions (#35356)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-04 16:17:11 -05:00
opencode-agent[bot]
bcbbf32569
fix(app): use v2 tooltip for prompt context (#35351)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
2026-07-04 14:54:46 -04:00
Simon Klee
7a8e7c88f4
tui: preserve spinner registration (#35292) 2026-07-04 09:44:23 +02:00
Luke Parker
a22676720d
fix(app): match new session panel corners (#35257) 2026-07-04 04:08:15 +00:00
Luke Parker
f63a451b6f
fix(desktop): stabilize esm shim injection (#35270) 2026-07-04 13:31:13 +10:00
opencode-agent[bot]
8b6a2b173b
fix(app): delay initial tab preview (#35266)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-03 22:42:17 -04:00
Luke Parker
933dbfdb36
fix(app): restore unfocused composer typing (#35249) 2026-07-03 23:59:23 +00:00
opencode-agent[bot]
b44bc0ad07
cleanup: tweak compaction prompt (#35220)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 15:26:03 -05:00
opencode-agent[bot]
436cd39d14 chore: generate 2026-07-03 18:42:23 +00:00
Aiden Cline
ed6dc879be
feat(opencode): gate execute tool behind code mode flag (#35185) 2026-07-03 13:41:15 -05:00
James Long
96d53c6716
refactor(core): move path resolve into fs service (#35202) 2026-07-03 13:25:25 -04:00
Aiden Cline
911fb7063a
fix(tui): align execute child calls with task indentation (#35190) 2026-07-03 12:06:58 -05:00
Aiden Cline
27e8e2e22b
refactor(opencode): fail the execute tool on program failure (#35180) 2026-07-03 11:27:36 -05:00
Aiden Cline
39c6dd1c32
feat(tui): render code-mode execute tool with child calls (#35113) 2026-07-03 11:17:21 -05:00
opencode-agent[bot]
a09447bc9b chore: generate 2026-07-03 15:40:10 +00:00
Adam
16815473a4
chore: artifacts 2026-07-03 10:38:49 -05:00
Adam
1f47bbfce2
fix(stats): link market share labs 2026-07-03 10:34:13 -05:00
James Long
33ed95f09c
fix(core): rewrite replacements while hoisting layers (#35175) 2026-07-03 11:26:49 -04:00
opencode-agent[bot]
2152624f37 chore: generate 2026-07-03 15:16:03 +00:00
Adam
7d84cb05ef
feat(stats): add model momentum section 2026-07-03 10:13:56 -05:00
James Long
eba0bd0397
chore: upgrade turbo (#35173) 2026-07-03 10:41:17 -04:00
Kit Langton
9aab24e95e
fix(opencode): provide Observability beneath all route service graphs (#35171) 2026-07-03 10:00:30 -04:00
Aarav Sareen
a4fed69a82
feat(app): dropdown search fix (#34961)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-03 10:31:37 +00:00
opencode-agent[bot]
41a3cfcdd9 chore: update nix node_modules hashes 2026-07-03 09:37:53 +00:00
opencode-agent[bot]
72845e09fc chore: generate 2026-07-03 09:25:41 +00:00
Aiden Cline
abaab29cb3
feat(opencode): add code-mode MCP adapter (#35085) 2026-07-03 09:24:27 +00:00
opencode-agent[bot]
30936a9bca chore: generate 2026-07-03 08:11:01 +00:00
Aarav Sareen
a9144eccf8
feat(app): improvements to model search (#34954) 2026-07-03 08:09:51 +00:00
Aiden Cline
458ec7b372
refactor(opencode): expose MCP tools in native shape from the service (#35103) 2026-07-03 03:05:36 -05:00
usrnk1
d46c02ba73
feat(desktop): papercut fixes (#34939) 2026-07-03 08:04:59 +00:00
opencode-agent[bot]
caedf36844 chore: generate 2026-07-03 07:54:40 +00:00
Aarav Sareen
4c6e2a92ce
feat(app): align subagent UI with v2 (#34931)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-03 15:53:31 +08:00
usrnk1
bf58fae51f
feat(desktop): add recently closed projects to home (#34926) 2026-07-03 15:52:40 +08:00
Aiden Cline
eb3476660f
chore: remove root node-gyp pin after Node 24 setup (#35092) 2026-07-03 00:45:17 -05:00
opencode-agent[bot]
7d48a22a63 chore: update nix node_modules hashes 2026-07-03 05:33:25 +00:00
opencode-agent[bot]
3f0fc22b6e chore: generate 2026-07-03 05:20:28 +00:00
Aiden Cline
2409c7a3d5
feat(codemode): add confined execution package (#35079) 2026-07-03 05:19:11 +00:00
Aiden Cline
379adee35c
revert: back out experimental codemode (#35077) 2026-07-02 23:57:30 -05:00
Luke Parker
2ef1a5991c
fix(ci): setup Node 24 before bun install for node-gyp (#35076) 2026-07-03 14:55:57 +10:00
opencode-agent[bot]
83c638eaac chore: generate 2026-07-03 04:49:44 +00:00
Aiden Cline
cb93114424
feat: experimental codemode (#34677) 2026-07-02 23:48:34 -05:00
Luke Parker
04d236ceed
fix(app): keep v2 review pane mounted across session tab switches (#35074) 2026-07-03 14:22:20 +10:00
Frank
5455fed78e zen: new inference 2026-07-02 21:59:33 -04:00
Frank
6dba0cc498 zen: new inference 2026-07-02 21:56:52 -04:00
Luke Parker
c07ac0db2f
feat(app): navigate tabs on mousedown in new layout (#35042) 2026-07-03 11:47:06 +10:00
Frank
20b090e493 zen: new inference 2026-07-02 21:38:29 -04:00
Frank
cb54824f2f zen: new inference 2026-07-02 21:28:38 -04:00
Dustin Deus
3adfb970bf
feat(tui): add debug info dialog with copy to clipboard (#35004) 2026-07-02 23:00:53 +02:00
opencode-agent[bot]
5ecd19db9f chore: generate 2026-07-02 19:43:04 +00:00
Adam
299daa2815
feat(stats): redesign model hero 2026-07-02 14:41:22 -05:00
Adam
7457139849
fix(stats): polish lab pages 2026-07-02 14:38:16 -05:00
Aiden Cline
373cd08b98
fix(copilot): honor advertised model endpoints (#34958) 2026-07-02 10:23:57 -05:00
Luke Parker
f52424e05f
fix(ui): resolve toast icon inside the toast component (#34874) 2026-07-02 15:53:47 +08:00
Luke Parker
4a42caef2c
fix(app): keep terminal mounted when switching session tabs in a workspace (#34852)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-02 15:53:19 +08:00
Aarav Sareen
7d2618637f
feat(app): v2 review panel overhaul (#31882)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-02 07:41:58 +00:00
Luke Parker
fbb95a6ee3
fix(app): seed session status before warming session info (#34864) 2026-07-02 16:32:25 +10:00
Brendan Allan
40233595b0
fix(app): clear original prompt after retarget (#34863) 2026-07-02 06:24:53 +00:00
opencode-agent[bot]
403164a6ac chore: generate 2026-07-02 06:08:44 +00:00
Luke Parker
1fb2ecc721
fix(app): scope session tab indicators to the tab's server (#34861) 2026-07-02 16:07:35 +10:00
Luke Parker
27c9b130c7
test(opencode): cover Windows session list directory spellings (#34857) 2026-07-02 05:16:17 +00:00
Brendan Allan
5fecf7ae9e
fix(app): only allow \(...\) syntax for inline latex (#34850) 2026-07-02 13:02:17 +08:00
Luke Parker
a0d64b12c9
fix(opencode): filter session list by resolved instance directory (#34842) 2026-07-02 04:39:49 +00:00
opencode-agent[bot]
d6b897188a chore: generate 2026-07-02 04:25:52 +00:00
Luke Parker
39dfbb53d0
fix(app): resolve target session lineage outside router transition (#34838) 2026-07-02 14:24:39 +10:00
opencode-agent[bot]
a39db9c6f6 chore: update nix node_modules hashes 2026-07-02 03:36:41 +00:00
opencode-agent[bot]
6ca60d9204
fix(opencode): update Cerebras SDK reasoning replay (#34826)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-01 22:22:27 -05:00
opencode-agent[bot]
b3934992d1 chore: update nix node_modules hashes 2026-07-02 02:52:09 +00:00
opencode-agent[bot]
4de7e90d8d
fix(ui): raise v2 tooltip layer (#34455)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-02 10:45:13 +08:00
usrnk1
917eca4f55
feat(desktop): refine session tab preview popover (#34792) 2026-07-02 10:41:25 +08:00
Luke Parker
3cf71808c4
fix(app): stabilize session timeline layout continuity (#34533) 2026-07-02 12:36:47 +10:00
Luke Parker
f266e829cf
fix(desktop): keep window tabs across app close (#34807) 2026-07-01 23:23:11 +00:00
Frank
3df5556223 zen: new inference 2026-07-01 17:54:22 -04:00
Adam
fc29f99190
fix(stats): include locale header in vary (#34789) 2026-07-01 15:25:49 -05:00
Adam
beb586a383
fix(stats): refine lab page tooltips 2026-07-01 14:54:35 -05:00
opencode-agent[bot]
5668d36477 chore: generate 2026-07-01 19:45:26 +00:00
Adam
3706b33ee0
feat(stats): use catalog descriptions 2026-07-01 14:41:28 -05:00
Adam
85e8ac3b36
feat(data): another redesigned lab section 2026-07-01 14:41:28 -05:00
Adam
91ca75c4dc
feat(stats): redesign lab usage 2026-07-01 14:41:28 -05:00
Adam
262ef4fdc2
feat(stats): add lab overview 2026-07-01 14:41:28 -05:00
Adam
74e5644646
feat(stats): redesign lab hero 2026-07-01 14:41:28 -05:00
opencode-agent[bot]
7de5aa08ff chore: update nix node_modules hashes 2026-07-01 15:40:08 +00:00
Vladimir Glafirov
9495e3e903
chore: bump gitlab-ai-provider to 6.10.0 (#34756) 2026-07-01 10:23:07 -05:00
opencode
1e73b76ea6 sync release versions for v1.17.13 2026-07-01 15:19:04 +00:00
Aarav Sareen
6697cf3fd8
feat(app): minor fixes (#34748) 2026-07-01 21:10:53 +08:00
opencode-agent[bot]
87aa62d1e2 chore: generate 2026-07-01 12:41:15 +00:00
usrnk1
3136b1ba97
feat(desktop): improve markdown heading spacing in session timeline (#34738) 2026-07-01 20:39:12 +08:00
Aarav Sareen
246dea3a58
feat(app): more v2 ui alignment (#34465)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 12:14:51 +00:00
opencode-agent[bot]
4b1c7f28a0 chore: generate 2026-07-01 12:08:40 +00:00
Aarav Sareen
9410eda4e3
feat(app): composer improvements (#34720)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 12:06:41 +00:00
usrnk1
f014686b07
feat(desktop): session tab hover preview popover (#34678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 17:16:45 +08:00
opencode-agent[bot]
8b68dc0d7f chore: generate 2026-07-01 09:05:06 +00:00
Brendan Allan
2f4d36d64e
fix(desktop): clean stale draft stores (#34651) 2026-07-01 09:03:27 +00:00
opencode-agent[bot]
6f2ff35656 chore: generate 2026-07-01 08:17:22 +00:00
Julian Coy
cd00422298
fix(app): question UI fixes and UX improvements (#34116)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-01 08:15:31 +00:00
opencode-agent[bot]
34ee94232f chore: generate 2026-07-01 07:40:25 +00:00
Aarav Sareen
4d91f0cf28
feat(app): v2 wsl ui (#34233)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-01 07:39:00 +00:00
opencode-agent[bot]
bf18cc971f chore: generate 2026-07-01 07:06:04 +00:00
Brendan Allan
5e1a1ed2e2
fix(app): scope session page errors (#34254) 2026-07-01 15:04:18 +08:00
opencode-agent[bot]
bd7f7ad98b chore: generate 2026-07-01 07:03:41 +00:00
Brendan Allan
af72cec799
feat(desktop): scope tabs to windows (#34669) 2026-07-01 07:02:10 +00:00
Aiden Cline
55552c521f
fix(provider): force openai reasoning variants (#34702) 2026-06-30 22:45:39 -05:00
opencode-agent[bot]
2b611a5b14 chore: update nix node_modules hashes 2026-07-01 01:44:56 +00:00
opencode-agent[bot]
45a437cbb2
fix(app): clamp virtualizer range to scroll bounds (#34588)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-01 11:28:34 +10:00
opencode-agent[bot]
439ec9b76f chore: generate 2026-07-01 00:53:45 +00:00
opencode-agent[bot]
ae3fefb42a
fix(session-ui): recognize more inline file paths (#34688)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-01 00:52:10 +00:00
Aiden Cline
f1407e41c4
fix(core): stop replaying stale GitHub Copilot Responses item IDs (#34686) 2026-06-30 19:19:02 -05:00
opencode-agent[bot]
c88ca972cf chore: generate 2026-06-30 23:59:52 +00:00
Frank
928c37bc02 zen: kk2.7, mm3, sonnet5 2026-06-30 19:56:25 -04:00
opencode
afff74eb2c sync release versions for v1.17.12 2026-06-30 19:48:01 +00:00
Aiden Cline
3a669d528b
fix(provider): enable sonnet 5 adaptive thinking (#34673) 2026-06-30 14:03:05 -05:00
Jack
6789214b31
fix(console): remove expired Go MiniMax promo (#34663) 2026-07-01 01:42:05 +08:00
James Long
6636683323
refactor: remove remaining default layer aliases (#34660) 2026-06-30 13:17:34 -04:00
opencode-agent[bot]
3af9f64265 chore: generate 2026-06-30 17:07:39 +00:00
James Long
5a23bdcb69
refactor(core): remove domain layer exports (#34625) 2026-06-30 13:05:15 -04:00
opencode-agent[bot]
e78726854a chore: generate 2026-06-30 16:19:11 +00:00
James Long
472d0f376e
refactor(core): remove infrastructure layer exports (#34624) 2026-06-30 12:17:12 -04:00
Brendan Allan
c8fde60ad4
fix(app): hide missing workspace branch (#34649) 2026-07-01 00:05:09 +08:00
opencode-agent[bot]
a4b6047e64
fix(core): drop legacy config filename (#34645)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 11:03:53 -05:00
James Long
6387f955cf
refactor(core): remove session layer exports (#34623) 2026-06-30 11:44:08 -04:00
James Long
5691f36e98
refactor(core): remove tool layer exports (#34622) 2026-06-30 11:18:41 -04:00
opencode-agent[bot]
74e7a378bb chore: generate 2026-06-30 15:02:51 +00:00
usrnk1
fced9c5a2f
feat(desktop): polish tooltips and session search (#34632) 2026-06-30 23:00:25 +08:00
opencode-agent[bot]
20445ca031
fix(cli): hide auto approval aliases in help (#34641)
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 14:52:57 +00:00
James Long
6fc9405618
refactor(core): route aggregate layers through nodes (#34621) 2026-06-30 10:46:44 -04:00
James Long
c86066efef
test(opencode): update help snapshots (#34639) 2026-06-30 10:22:21 -04:00
usrnk1
fa9ba2938e
feat(desktop): make error view draggable (#34627) 2026-06-30 22:12:43 +08:00
usrnk1
63e24880e5
feat(desktop): polish inline session title editing (#34607) 2026-06-30 22:12:22 +08:00
opencode-agent[bot]
eaf42c4ab0 chore: update nix node_modules hashes 2026-06-30 13:55:24 +00:00
Aiden Cline
8e10ab0aa7
chore: bump venice provider (#34629) 2026-06-30 08:40:09 -05:00
runvip
b329fcceb2
fix(docs): fix Russian translation for index.mdx (#34001)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-30 16:47:44 +05:30
opencode-agent[bot]
bd83177c48 chore: generate 2026-06-30 11:07:49 +00:00
Brendan Allan
06cb0de0b1
feat(app): autocomplete mcp resources (#34597) 2026-06-30 19:05:53 +08:00
opencode-agent[bot]
3ca89ac796
fix(app): autocomplete configured references (#34308)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-30 18:45:37 +08:00
Brendan Allan
3aa2860058
fix(app): restore prompt cursor on focus (#34175) 2026-06-30 18:40:20 +08:00
usrnk1
ad63cf1590
feat(app): hide separators around active tabs (#34591) 2026-06-30 18:38:42 +08:00
Brendan Allan
aa56750f8b
fix(desktop): persist last active url (#34595) 2026-06-30 18:32:30 +08:00
opencode-agent[bot]
4aaed42640
fix(app): preserve macos titlebar inset (#34594)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 18:17:55 +08:00
usrnk1
f69f677a32
feat(desktop): add hover background to session title and single-click edit (#34589) 2026-06-30 17:46:52 +08:00
opencode-agent[bot]
8289883de8 chore: generate 2026-06-30 09:11:40 +00:00
usrnk1
003c22b4a3
fix(desktop): context menu button / tab intermittent issue (#34420)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-30 17:09:38 +08:00
opencode-agent[bot]
1982d9832b
fix(session-ui): align code copy tooltip with v2 style (#34459)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 17:01:38 +08:00
opencode-agent[bot]
90f0576222
fix(session-ui): deselect review line on draft cancel (#34585)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-30 16:26:20 +08:00
usrnk1
e34822db6b
fix(desktop): preserve selected model during session promotion (#34466) 2026-06-30 16:23:39 +08:00
Frank
b19ae6547f sync 2026-06-30 03:23:50 -04:00
opencode-agent[bot]
6b074ed718
fix(app): suppress middle-click tab auxclick (#34355)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 16:32:25 +10:00
opencode-agent[bot]
e687eb936b
fix(app): register export logs globally (#34352)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 16:31:09 +10:00
opencode-agent[bot]
797cb530e5 chore: generate 2026-06-30 05:09:55 +00:00
Dax
0a5bed2bc2
feat(tui): add yolo permission mode (#33279)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-30 00:08:29 -05:00
opencode-agent[bot]
ba41dadc91
ci: skip issue actions for team authors (#34556)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-29 23:48:58 -05:00
James Long
451876b0bd
refactor(opencode): remove core service layer exports (#34518) 2026-06-29 23:32:16 -04:00
James Long
91561bbdfb
refactor(opencode): remove app service layer exports (#34517) 2026-06-29 23:03:57 -04:00
opencode-agent[bot]
018ce34b39
revert(core): skip fff in node runtime (#34549)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:58:57 +10:00
opencode-agent[bot]
ce82b21073 chore: generate 2026-06-30 02:55:10 +00:00
James Long
1f37c26574
refactor(opencode): use layer nodes in remaining harnesses (#34516) 2026-06-29 22:53:42 -04:00
opencode-agent[bot]
cb3e28d207
fix(app): collapse review pane by default (#34165)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:47:00 +10:00
opencode-agent[bot]
8f1e13f299
fix(core): skip fff in node runtime (#34353)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-30 12:38:47 +10:00
Luke Parker
2fe68b5e91
fix(ui): prevent tool status blank frame (#34547)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:26:04 +10:00
opencode-agent[bot]
2ace237938 chore: generate 2026-06-30 01:23:03 +00:00
James Long
5b62211167
refactor(opencode): build runtimes from layer nodes (#34515) 2026-06-29 21:21:28 -04:00
Frank
60b6229c4e zen: track region 2026-06-29 19:57:13 -04:00
opencode-agent[bot]
4caec80e67 chore: generate 2026-06-29 23:03:24 +00:00
Frank
e9d4ca7e0f zen: track region 2026-06-29 18:57:32 -04:00
Aiden Cline
d71454c701
fix(core): expose models.dev modes as models (#34521) 2026-06-29 17:51:06 -05:00
Dustin Deus
884c256033
fix(console): cancel upstream provider requests (#34467)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-06-29 23:28:04 +02:00
opencode-agent[bot]
78235385dd chore: generate 2026-06-29 20:53:48 +00:00
Aiden Cline
fd213e6df6
fix(mcp): prefer content over structured output (#34505) 2026-06-29 15:51:52 -05:00
James Long
3726052307
refactor(opencode): use layer nodes in server tests (#34503) 2026-06-29 16:50:59 -04:00
opencode-agent[bot]
7d33a6f7c9 chore: generate 2026-06-29 20:36:56 +00:00
James Long
7a035d7fc0
refactor(opencode): bind instance bootstrap node (#34502) 2026-06-29 16:35:10 -04:00
opencode-agent[bot]
9151af7045 chore: generate 2026-06-29 20:24:42 +00:00
James Long
15bcbb1d7a
refactor(opencode): migrate session tests to layer nodes (#34494) 2026-06-29 16:22:50 -04:00
opencode-agent[bot]
4d3294727c chore: generate 2026-06-29 20:07:10 +00:00
James Long
aae0e89519
refactor(opencode): use layer nodes in plugin tests (#34495) 2026-06-29 16:04:58 -04:00
opencode-agent[bot]
93a7b4ab76 chore: generate 2026-06-29 19:58:09 +00:00
James Long
0ebe74b625
refactor(opencode): migrate llm tests to layer nodes (#34479) 2026-06-29 15:55:58 -04:00
James Long
b9dae8593c
refactor(opencode): migrate compaction and workspace tests to layer nodes (#34478) 2026-06-29 15:42:52 -04:00
opencode-agent[bot]
d8f9388610 chore: generate 2026-06-29 18:52:55 +00:00
James Long
be14739cce
refactor(core): convert config tests to nodes (#34474) 2026-06-29 14:51:13 -04:00
James Long
762588c251
refactor(core): convert prompt tests to nodes (#34470) 2026-06-29 14:25:33 -04:00
opencode-agent[bot]
d6e54e9042 chore: generate 2026-06-29 17:58:20 +00:00
James Long
d4fd528152
refactor(core): convert more opencode tests to nodes (#34464) 2026-06-29 13:56:28 -04:00
opencode-agent[bot]
de185559dd chore: generate 2026-06-29 16:58:51 +00:00
James Long
bc5ce5eab1
refactor(core): convert opencode tests to nodes (#34453) 2026-06-29 12:56:44 -04:00
opencode-agent[bot]
b10d617c80 chore: generate 2026-06-29 16:17:54 +00:00
Shoubhit Dash
18466b8020
feat(llm): add tool schema projections (#34454) 2026-06-29 21:45:42 +05:30
opencode-agent[bot]
71ec022b47 chore: generate 2026-06-29 15:50:24 +00:00
Shoubhit Dash
f7eeb08942
fix(llm): narrow raw overlays (#34448) 2026-06-29 21:18:06 +05:30
opencode-agent[bot]
9205dfe724 chore: generate 2026-06-29 15:37:20 +00:00
James Long
a3776429aa
refactor(core): finish test layer node conversion (#34385) 2026-06-29 11:35:17 -04:00
opencode-agent[bot]
c0e43c0c65 chore: generate 2026-06-29 13:57:06 +00:00
Shoubhit Dash
08c5a2a5e8
feat(llm): enforce request precedence (#34440) 2026-06-29 19:24:37 +05:30
opencode-agent[bot]
7077c70d60 chore: generate 2026-06-29 13:41:59 +00:00
Shoubhit Dash
1fd8bf526d
feat(llm): add model defaults and compatibility data (#34436) 2026-06-29 19:10:00 +05:30
opencode-agent[bot]
6d9539f469
fix: exempt org issues from compliance close (#34431) 2026-06-29 12:59:30 +00:00
Shoubhit Dash
e5101d9651
test(llm): lock event reducer laws (#34423) 2026-06-29 17:22:46 +05:30
Shoubhit Dash
b0151e1d02
test(llm): verify generate reducer law (#34418) 2026-06-29 17:01:45 +05:30
opencode-agent[bot]
c3637753bc chore: generate 2026-06-29 10:58:22 +00:00
Shoubhit Dash
48fc9e3cc3
feat(llm): add response reducer (#34417) 2026-06-29 16:26:33 +05:30
opencode-agent[bot]
82a482b36d chore: generate 2026-06-29 10:45:44 +00:00
Aarav Sareen
7fac84319d
feat(app): align slash popover to v2 tokens (#34286)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-29 18:44:18 +08:00
Aarav Sareen
0a5e617da8
feat(app): update message part ui to v2 (#34394) 2026-06-29 18:43:06 +08:00
Jack
b5f92c9f48
docs: fix Kimi K2.7 Go model ID (#34413) 2026-06-29 18:39:43 +08:00
Aarav Sareen
2070fd9bc2
feat(app): improve projects sidebar reactivity (#34391)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-29 18:20:26 +08:00
opencode-agent[bot]
48dc05eee7 chore: generate 2026-06-29 10:13:23 +00:00
Aarav Sareen
84bb706537
feat(app): new timeline header (#34192) 2026-06-29 18:11:32 +08:00
opencode-agent[bot]
be8cfa7e08 chore: generate 2026-06-29 09:51:40 +00:00
Aarav Sareen
56a789d926
feat(app): show loader on session hover (#34224) 2026-06-29 17:50:14 +08:00
Aarav Sareen
5409151ad4
feat(app): sticky session list header (#34220)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-29 17:49:10 +08:00
Frank
f90d154465 zen: budget 2026-06-29 05:46:56 -04:00
Frank
078385d386 zen: budget 2026-06-29 05:46:56 -04:00
Filip
beaaa174ea
fix(app): disable empty server chevron (#34292) 2026-06-29 15:28:01 +08:00
opencode-agent[bot]
fb59606bb4
test(core): fix layer node replacement type expectation (#34386)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-29 00:03:02 -05:00
opencode-agent[bot]
846d548154 chore: generate 2026-06-29 03:49:43 +00:00
James Long
84336e4f91
refactor(core): refine layer node replacements (#34377) 2026-06-28 23:48:18 -04:00
OpeOginni
01a5c69244
feat(tui): integrate ServerAuth headers into transport configuration for external served TUI thread (#29876) 2026-06-28 19:24:27 -05:00
opencode-agent[bot]
d78f91afeb chore: generate 2026-06-28 23:36:32 +00:00
Brendan Allan
33762292f7
fix(app): wrap model.set in startTransition (#34351) 2026-06-29 07:35:09 +08:00
opencode-agent[bot]
5d6aa3b41a chore: generate 2026-06-28 23:18:35 +00:00
OpeOginni
683aca5dbe
feat(desktop): Display stored totals for Tokens and Cost in Desktop Session Context (#28887) 2026-06-28 23:17:08 +00:00
Aiden Cline
92025e9a47
fix(mcp): clarify debug oauth probe (#34350) 2026-06-28 18:16:29 -05:00
Max Anderson
411e053572
fix(mcp): reconnect after OAuth even when server is disabled
Closes #33915
2026-06-28 18:07:10 -05:00
Frank
b862d178bf zen: update alert role 2026-06-28 18:53:32 -04:00
Frank
bda0ddc207 zen: new inference 2026-06-28 13:48:42 -04:00
Brendan Allan
58ba99e505
fix(desktop): avoid destroyed window permission checks (#34300) 2026-06-28 20:06:15 +08:00
Filip
6ee817d041
fix(app): disable add project when given server is offline (#34294) 2026-06-28 18:45:05 +08:00
Kit Langton
dfeb1b5051
feat(client): generate complete protocol client (#34164) 2026-06-27 22:25:40 -04:00
opencode-agent[bot]
61a7f6db35 chore: update nix node_modules hashes 2026-06-28 01:20:08 +00:00
Dax
6446b8ae3b
fix(sdk): preserve V2Event name for SSE streams (#34171) 2026-06-27 21:05:47 -04:00
Brendan Allan
ae53163cad
fix(app): transition draft project updates (#34252) 2026-06-28 02:58:07 +08:00
opencode-agent[bot]
41202819a4 chore: generate 2026-06-27 18:11:37 +00:00
James Long
a31698f99b
refactor(core): move more tests to nodes (#34248) 2026-06-27 18:10:07 +00:00
opencode-agent[bot]
6248542c49 chore: generate 2026-06-27 17:20:42 +00:00
James Long
d25c91e5eb
refactor(core): move session test to nodes (#34245) 2026-06-27 13:19:14 -04:00
James Long
5d63020dcd
test(core): cover app node builder graphs (#34244) 2026-06-27 16:54:18 +00:00
opencode-agent[bot]
062f54590e chore: generate 2026-06-27 16:30:47 +00:00
James Long
a76c6918d2
refactor(core): rename app node modules (#34238) 2026-06-27 12:29:21 -04:00
Ben Guthrie
2b91a6f210
fix(tui): register prompt.skills keybinds (#34180) 2026-06-27 11:06:14 -05:00
opencode-agent[bot]
10579cceb2 chore: generate 2026-06-27 15:38:27 +00:00
Aarav Sareen
25702e01ce
feat(app): new debug bar (#34237) 2026-06-27 15:37:02 +00:00
James Long
ecc5c44d9a
refactor(core): make node build bind maps conditionally (#34218) 2026-06-27 11:09:07 -04:00
Aarav Sareen
f5a0b920a2
feat(app): minor visual updates (#34205) 2026-06-27 18:07:17 +08:00
Brendan Allan
2caa016fe1
fix(app): batch new session tab navigation (#34196) 2026-06-27 17:13:09 +08:00
opencode-agent[bot]
6861fedd09 chore: generate 2026-06-27 07:43:42 +00:00
Luke Parker
3d072112ce
fix(app): reconcile session pages with concurrent events (#34042) 2026-06-27 17:42:17 +10:00
OpeOginni
bdfea046db
fix(desktop): recognize normal auth metadata input prompts in connect provider dialog (#33024) 2026-06-27 05:19:59 +00:00
Aarav Sareen
a2d08fb63b
feat(app): update home screen alignment + markdown styles (#34172) 2026-06-27 13:00:52 +08:00
opencode-agent[bot]
5a55135d89
fix(ui): make select hover feedback immediate (#34121)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:47:32 +08:00
opencode-agent[bot]
8870d36e0f
fix(app): space home sessions from scrollbar (#34132)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:46:33 +08:00
opencode-agent[bot]
eb923c27ca chore: update nix node_modules hashes 2026-06-27 04:27:56 +00:00
opencode-agent[bot]
9903abc704
fix(opencode): allow empty provider config default (#34167)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-26 23:19:42 -05:00
opencode-agent[bot]
225a1fbf35 chore: generate 2026-06-27 04:10:30 +00:00
Dax Raad
93159bccbf feat(core): port v2 runtime fixes onto dev
Cherry-picks the packages/core changes from the v2 branch onto dev:
- combined ordered stdout/stderr in AppProcess + bash structured output
- edit/apply-patch return FileDiff info with status and line stats
- ignore no-op model switches; record reasoning timestamps
- return unexpected local tool defects to the model and continue
- keep OAuth account metadata out of request bodies
- nest OpenAI reasoning effort/summary options
- load OpenCode provider config asynchronously; batch plugin boot
- export latest public event manifest

Includes the supporting schema reasoning time field and regenerated
client/SDK types.
2026-06-27 00:07:25 -04:00
opencode-agent[bot]
fab8ec4f54
fix(app): migrate composer tooltips to v2 (#34147)
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 14:01:45 +10:00
opencode-agent[bot]
a3035c53ea chore: update nix node_modules hashes 2026-06-27 03:47:28 +00:00
Aiden Cline
36c416e143
fix(mcp): request refresh token scope (#34125) 2026-06-26 22:27:43 -05:00
Aiden Cline
e1e0304a96
fix(mcp): surface OAuth completion errors (#34145) 2026-06-26 22:21:16 -05:00
opencode-agent[bot]
71c3a7c8f2 chore: generate 2026-06-27 02:47:33 +00:00
James Long
ecdfff5a42
refactor(core): separate out location node functionality and integrate into v2 (#34119) 2026-06-26 22:46:07 -04:00
opencode-agent[bot]
4b948c5d74
fix(app): hide home session archive action (#34136)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 02:23:36 +00:00
opencode-agent[bot]
cd56c51e2d
fix(app): keep bare slash as plain inline code (#34122)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 22:21:01 +00:00
opencode-agent[bot]
43e39d7f68
fix(tui): use generated event union (#34118)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 18:04:53 -04:00
Frank
7a17925495 zen: new inference 2026-06-26 16:25:07 -04:00
Brendan Allan
5acb2530b4
fix(app): centralize notification state (#34105) 2026-06-26 19:24:33 +00:00
opencode-agent[bot]
44a6787359 chore: generate 2026-06-26 19:18:18 +00:00
Kit Langton
42e6b7db32
feat(sdk): expose live event stream (#34098) 2026-06-26 21:16:33 +02:00
opencode-agent[bot]
2c02f8bace chore: update nix node_modules hashes 2026-06-26 18:53:34 +00:00
Affan Ali
2ec20e576b
fix(app): slow tooltip display for models (#30745)
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
2026-06-27 02:53:08 +08:00
opencode-agent[bot]
20f47fec7a chore: generate 2026-06-26 18:51:42 +00:00
Kit Langton
65210f2d97
feat(api): add finite durable session history pages (#34097) 2026-06-26 14:49:47 -04:00
Brendan Allan
af0b7ffae7
fix(app): animate prompt selectors after loading (#34101) 2026-06-27 02:36:01 +08:00
opencode-agent[bot]
0e2dd4ad15 chore: generate 2026-06-26 17:56:55 +00:00
Dax Raad
11d2f3e5f8 fix(llm): end reasoning before responses 2026-06-26 13:55:04 -04:00
Dax Raad
1ac6b4bec4 fix(core): authorize external read paths 2026-06-26 13:55:04 -04:00
opencode-agent[bot]
0befd9b049 chore: generate 2026-06-26 17:53:26 +00:00
Dax Raad
850a0dfe7c fix(core): resolve prompt attachment mime types 2026-06-26 13:51:00 -04:00
opencode-agent[bot]
18a419e634 chore: generate 2026-06-26 17:36:45 +00:00
Adam
82f47cb312
feat(stats): add clickable section headings (#34095) 2026-06-26 12:34:44 -05:00
Adam
7b1fe33ed3
fix(data): clarify unchanged rank 2026-06-26 12:29:29 -05:00
Stefan Avram
c72cca8def
docs: route enterprise contact links (#34080) 2026-06-26 12:05:24 -05:00
Adam
05ce6bc275
fix(data): canonicalize locale pages 2026-06-26 12:00:32 -05:00
Shoubhit Dash
1de7368580
fix(acp): send partial completed tool updates (#34091) 2026-06-26 22:21:35 +05:30
Brendan Allan
36f901588a
fix(app): wait for persisted home state before reading collapsed state (#34088) 2026-06-27 00:25:43 +08:00
Ariane Emory
92f1a17b67
docs(providers): document blacklist and whitelist model filtering (#33792) 2026-06-26 11:09:14 -05:00
Shoubhit Dash
ebf4007efd
fix(acp): enrich permission prompts (#34079) 2026-06-26 21:25:52 +05:30
Brendan Allan
1aea999d7c
fix(app): consolidate add project action (#34086) 2026-06-26 23:54:31 +08:00
Brendan Allan
4a8fee3b2d
feat(app): add new session workspace controls (#34085) 2026-06-26 23:43:41 +08:00
Aiden Cline
0d3e0fc8f4
fix(core): enlarge OAuth callback card (#34082) 2026-06-26 10:16:51 -05:00
Aiden Cline
f254476043
fix(llm): omit stateless response item ids (#34027) 2026-06-26 10:14:43 -05:00
Aiden Cline
639c8e6475
tweak: update OAuth callback branding (#34081) 2026-06-26 10:08:29 -05:00
Aiden Cline
e3bfd4cce6
fix(mcp): refresh credentials on reauthentication (#33717) 2026-06-26 10:03:39 -05:00
Shoubhit Dash
c2882268d6
fix(acp): skip resume transcript replay (#34070) 2026-06-26 14:12:05 +00:00
Aarav Sareen
9c38b91e91
feat(app): updates to design system (#34066) 2026-06-26 13:22:35 +00:00
Shoubhit Dash
6e8e772582
fix(acp): surface prompt errors (#34061) 2026-06-26 12:20:38 +00:00
Shoubhit Dash
971518c6d9
fix(core): refresh cached remote skills (#34059) 2026-06-26 12:12:50 +00:00
Shoubhit Dash
ae853561cd
fix(opencode): preserve skill resource paths (#34052) 2026-06-26 16:48:30 +05:30
Luke Parker
753d312c28
fix(tui): preserve renderer initialization errors (#33996)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-26 15:14:28 +05:30
vimtor
eeb5b1d8bc
core: prevent UI imports compiling TSX source 2026-06-26 10:25:48 +02:00
opencode-agent[bot]
f8ceb30b43 chore: generate 2026-06-26 08:06:37 +00:00
usrnk1
78a5a030ce
feat(app): refine session UI styling (#33860)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-26 08:04:56 +00:00
opencode-agent[bot]
219ba24d90 chore: update nix node_modules hashes 2026-06-26 05:43:54 +00:00
Aiden Cline
e8fea9e63a
feat(oauth): unify OAuth callback browser pages (#34025) 2026-06-26 00:36:40 -05:00
opencode-agent[bot]
11537260aa chore: generate 2026-06-26 05:31:26 +00:00
Brendan Allan
929c4aaf29
refactor(app): replace tab drag handling with dndkit (#33880)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-06-26 05:29:58 +00:00
opencode-agent[bot]
19e510f5d2
refactor(app): use dropdown for project selector (#33984) 2026-06-26 13:18:23 +08:00
opencode-agent[bot]
8a1608ed1d chore: update nix node_modules hashes 2026-06-26 05:13:59 +00:00
opencode-agent[bot]
077deb9d82
fix(app): bump ghostty-web to prevent terminal resize hangs (#34020)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-06-26 14:59:18 +10:00
1939 changed files with 419028 additions and 145812 deletions

View file

@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@ -7,11 +8,16 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
neriousy
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
StarpTech
vaprdev

View file

@ -8,12 +8,22 @@ inputs:
runs:
using: "composite"
steps:
- name: Get baseline download URL
# node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22;
# some runner images ship an older system Node on PATH
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- 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 ;;
@ -23,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
@ -38,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
@ -63,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

@ -34,10 +34,48 @@ jobs:
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev',
});
const teamMembers = new Set(
Buffer.from(file.content, 'base64')
.toString()
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
);
function isExempt(item) {
const login = item.user?.login?.toLowerCase();
return (
login === agentLogin ||
orgMemberAssociations.has(item.author_association) ||
(login && teamMembers.has(login))
);
}
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const login = item.user?.login;
if (isExempt(item)) {
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,

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

@ -17,12 +17,31 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Check duplicates and compliance
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -35,9 +54,10 @@ 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 }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@ -49,6 +69,8 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
@ -83,7 +105,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant, start the comment with:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@ -129,12 +151,31 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -147,10 +188,13 @@ 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 }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:

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

@ -16,13 +16,32 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.author.outputs.skip != 'true'
uses: ./.github/actions/setup-bun
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Triage issue
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -30,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/claude-opus-4-8
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", "starptech"],
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:

View file

@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

View file

@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

3
artifacts/glm52-rise-video/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
.remotion
out/frame-*.png

View file

@ -0,0 +1,483 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "glm52-rise-video",
"dependencies": {
"@remotion/cli": "^4.0.384",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "^4.0.384",
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="],
"@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="],
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="],
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="],
"@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
"@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="],
"@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="],
"@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="],
"@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="],
"@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="],
"@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="],
"@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="],
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="],
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="],
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="],
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="],
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="],
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="],
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="],
"@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="],
"@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="],
"@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="],
"@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="],
"@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="],
"@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="],
"@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="],
"@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="],
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="],
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="],
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="],
"@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="],
"@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
"@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
"@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
"@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="],
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="],
"memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="],
"postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="],
"postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="],
"postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
"terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="],
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,24 @@
{
"name": "glm52-rise-video",
"private": true,
"type": "module",
"scripts": {
"render": "remotion render src/index.tsx GLM52Rise out/glm-52-broke-out.mp4 --codec h264 --pixel-format yuv420p",
"render:sheep": "remotion render src/index.tsx NZSheep out/nz-sheep.mp4 --codec h264 --pixel-format yuv420p",
"render:novel": "remotion render src/index.tsx NovelTokens out/novel-1984.mp4 --codec h264 --pixel-format yuv420p",
"render:flash": "remotion render src/index.tsx FlashShare out/flash-share.mp4 --codec h264 --pixel-format yuv420p",
"render:minimax": "remotion render src/index.tsx MiniMaxClimb out/minimax-climb.mp4 --codec h264 --pixel-format yuv420p",
"still:june": "remotion still src/index.tsx JuneTotals out/june-totals.png --frame=0"
},
"dependencies": {
"@remotion/cli": "^4.0.384",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "^4.0.384"
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -0,0 +1,156 @@
// Verified against the production opencode-stats PlanetScale DB.
// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'.
// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17.
// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others.
export type Day = {
date: string
total: number
glm: number
dsf: number
dsp: number
mm: number
others: number
}
export const days: Day[] = [
{
date: "Jun 12",
total: 2_283_799_449_383,
glm: 0,
dsf: 1_176_701_653_509,
dsp: 569_527_034_307,
mm: 159_016_250_684,
others: 378_554_510_883,
},
{
date: "Jun 13",
total: 2_008_462_388_420,
glm: 0,
dsf: 995_338_131_997,
dsp: 445_817_536_548,
mm: 211_743_241_967,
others: 355_563_477_908,
},
{
date: "Jun 14",
total: 2_007_785_405_251,
glm: 0,
dsf: 983_954_176_228,
dsp: 428_151_999_341,
mm: 262_476_527_930,
others: 333_202_701_752,
},
{
date: "Jun 15",
total: 2_694_736_103_062,
glm: 0,
dsf: 1_255_893_953_859,
dsp: 632_223_338_376,
mm: 352_507_442_991,
others: 454_111_367_836,
},
{
date: "Jun 16",
total: 2_838_153_758_908,
glm: 0,
dsf: 1_336_625_283_800,
dsp: 676_480_415_730,
mm: 305_268_829_013,
others: 519_779_230_365,
},
{
date: "Jun 17",
total: 2_778_964_711_109,
glm: 70_095_977_043,
dsf: 1_339_831_523_555,
dsp: 660_414_395_220,
mm: 251_302_096_157,
others: 457_320_719_134,
},
{
date: "Jun 18",
total: 2_806_992_430_656,
glm: 201_130_231_172,
dsf: 1_295_599_996_869,
dsp: 595_665_008_776,
mm: 322_205_104_324,
others: 392_392_089_515,
},
{
date: "Jun 19",
total: 2_419_611_630_232,
glm: 199_086_413_910,
dsf: 1_115_750_468_802,
dsp: 475_965_869_304,
mm: 303_586_698_735,
others: 325_222_179_481,
},
{
date: "Jun 20",
total: 2_188_278_916_865,
glm: 193_931_516_396,
dsf: 1_050_194_681_012,
dsp: 395_303_435_278,
mm: 281_998_000_337,
others: 266_851_283_842,
},
{
date: "Jun 21",
total: 2_042_309_961_344,
glm: 181_894_043_118,
dsf: 985_164_570_580,
dsp: 368_194_079_542,
mm: 259_812_551_324,
others: 247_244_716_780,
},
{
date: "Jun 22",
total: 2_893_934_325_663,
glm: 301_759_048_475,
dsf: 1_298_124_282_989,
dsp: 581_012_596_194,
mm: 371_581_117_839,
others: 341_457_280_166,
},
{
date: "Jun 23",
total: 3_109_009_321_480,
glm: 282_277_235_158,
dsf: 1_423_571_678_821,
dsp: 627_374_654_587,
mm: 429_416_300_508,
others: 346_369_452_406,
},
{
date: "Jun 24",
total: 2_939_149_971_595,
glm: 256_497_442_533,
dsf: 1_373_583_023_234,
dsp: 601_270_997_775,
mm: 391_586_493_231,
others: 316_212_014_822,
},
{
date: "Jun 25",
total: 3_029_641_552_948,
glm: 256_279_657_734,
dsf: 1_481_084_002_776,
dsp: 602_077_167_287,
mm: 375_985_302_874,
others: 314_215_422_277,
},
]
export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage
// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T
export const glmWeekTokensT = 1.672
// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into.
export const segments = [
{ key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true },
{ key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" },
{ key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" },
{ key: "mm", label: "minimax-m3", color: "#c8cdd3" },
{ key: "others", label: "other models", color: "#dde0e4" },
] as const

View file

@ -0,0 +1,185 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
gray: "#aab0b8",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens.
const bars = [
{ label: "deepseek-v4-flash", share: 48.3, hero: true },
{ label: "deepseek-v4-pro", share: 19.4 },
{ label: "minimax-m3", share: 13.0 },
{ label: "glm-5.2", share: 8.3 },
{ label: "mimo-v2.5", share: 4.3 },
{ label: "kimi-k2.7-code", share: 2.6 },
{ label: "other models", share: 4.1 },
]
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function FlashShare() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 22–28, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · SHARE OF TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>DeepSeek V4 Flash</div>
<div
style={{
fontSize: 88,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
48%
</div>
</div>
</div>
{/* bar chart */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 16 }}>
{bars.map((b, i) => {
const g = grow(i)
const pct = b.share * g
return (
<div key={b.label} style={{ display: "flex", alignItems: "center", gap: 18 }}>
<div
style={{
width: 268,
fontSize: 23,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.ink : c.muted,
textAlign: "right",
whiteSpace: "nowrap",
}}
>
{b.label}
</div>
<div style={{ position: "relative", flex: 1, height: 48 }}>
{/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${pct}%`,
background: b.hero ? c.accent : c.gray,
borderRight: b.hero ? `2px solid ${c.accentHi}` : "none",
}}
/>
</div>
<div
style={{
width: 84,
fontSize: 24,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.accent : c.muted,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(pct)}%
</div>
</div>
)
})}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 24,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
DeepSeek V4 Flash · 9.48T tokens · 83.6M requests
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,36 @@
import { Composition, registerRoot } from "remotion"
import { GLM52Rise } from "./video"
import { NZSheep } from "./sheep"
import { NovelTokens } from "./novel"
import { FlashShare } from "./flash"
import { MiniMaxClimb } from "./minimax"
import { JuneTotals } from "./june"
function Root() {
return (
<>
<Composition id="GLM52Rise" component={GLM52Rise} durationInFrames={240} fps={30} width={1080} height={1080} />
<Composition id="NZSheep" component={NZSheep} durationInFrames={150} fps={30} width={1080} height={1080} />
<Composition
id="NovelTokens"
component={NovelTokens}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
/>
<Composition id="FlashShare" component={FlashShare} durationInFrames={165} fps={30} width={1080} height={1080} />
<Composition
id="MiniMaxClimb"
component={MiniMaxClimb}
durationInFrames={165}
fps={30}
width={1080}
height={1080}
/>
<Composition id="JuneTotals" component={JuneTotals} durationInFrames={1} fps={30} width={1080} height={1080} />
</>
)
}
registerRoot(Root)

View file

@ -0,0 +1,144 @@
import React from "react"
import { AbsoluteFill } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#dcdcdc",
accent: "#3b5cf6",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026.
// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures.
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
function DotBand() {
return (
<div
style={{
height: 12,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left top",
maskPosition: "left top",
}}
/>
)
}
function Metric({ value, label }: { value: string; label: string }) {
return (
<div>
<div
style={{
fontSize: 92,
fontWeight: 600,
letterSpacing: -3,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
marginLeft: -8, // align the glyph's visual left edge to the column
}}
>
{value}
</div>
<div style={{ marginTop: 16, fontSize: 24, fontWeight: 500, color: c.muted, letterSpacing: 1 }}>{label}</div>
</div>
)
}
export function JuneTotals() {
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 22 }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>MONTHLY RECAP</div>
</div>
<DotBand />
{/* hero */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{ fontSize: 25, fontWeight: 600, color: c.muted, letterSpacing: 3 }}>OPENCODE GO · JUNE 2026</div>
<div style={{ marginTop: 14 }}>
<div
style={{
fontSize: 300,
fontWeight: 600,
letterSpacing: -10,
lineHeight: 0.82,
color: c.accent,
marginLeft: -22,
}}
>
73T
</div>
<div style={{ fontSize: 50, fontWeight: 600, color: c.ink, marginTop: 10 }}>tokens processed</div>
</div>
{/* supporting metrics */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
marginTop: 60,
paddingTop: 40,
borderTop: `1px solid ${c.line}`,
}}
>
<Metric value="650M" label="requests" />
<Metric value="11M" label="sessions" />
</div>
</div>
{/* footer */}
<DotBand />
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 22,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,201 @@
import React from "react"
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
accent: "#3b5cf6",
accentHi: "#5b78ff",
accentDim: "#aebcf3",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens.
// W26 2.559T is +23.2% / +482.3B vs W25 2.077T.
const weeks = [
{ label: "May 25", t: 0.008 },
{ label: "Jun 1", t: 0.429 },
{ label: "Jun 8", t: 1.192 },
{ label: "Jun 15", t: 2.077 },
{ label: "Jun 22", t: 2.559, latest: true },
]
const AXIS_MAX = 3.0
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
const CHART_H = 420 // multiple of 12 so the dotted tracks never clip
export function MiniMaxClimb() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 22–28, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · WEEKLY TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>MiniMax M3</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div
style={{
fontSize: 84,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
+23.2%
</div>
<div style={{ fontSize: 19, fontWeight: 500, color: c.muted, letterSpacing: 1, marginTop: 6 }}>
WEEK OVER WEEK
</div>
</div>
</div>
</div>
{/* column chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 30 }}>
<div style={{ width: "100%", display: "flex", alignItems: "flex-end", gap: 30 }}>
{weeks.map((w, i) => {
const g = grow(i)
const h = Math.round((w.t / AXIS_MAX) * CHART_H * g)
return (
<div key={w.label} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
{/* value + bar */}
<div style={{ position: "relative", width: "100%", height: CHART_H }}>
{/* dotted track */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: h,
background: w.latest ? c.accent : c.accentDim,
borderTop: w.latest ? `3px solid ${c.accentHi}` : "none",
}}
/>
{/* value label */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: h + 10,
textAlign: "center",
fontSize: 26,
fontWeight: w.latest ? 600 : 500,
color: w.latest ? c.accent : c.muted,
fontVariantNumeric: "tabular-nums",
opacity: interpolate(g, [0.5, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
{w.t.toFixed(2)}T
</div>
</div>
{/* week label */}
<div
style={{
marginTop: 14,
fontSize: 18,
fontWeight: 500,
color: w.latest ? c.ink : c.faint,
}}
>
{w.label}
</div>
</div>
)
})}
</div>
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 22,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
2.56T tokens last week · +482.3B added
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,135 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.74)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request
const AVG = 113116
const K = Math.round(AVG / 1000) // 113
const nf = new Intl.NumberFormat("en-US")
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NovelTokens() {
const frame = useCurrentFrame()
const k = Math.round(
K *
interpolate(frame, [18, 92], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
<Img
src={staticFile("book.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 34%",
transform: `scale(${zoom})`,
transformOrigin: "center 30%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 20%, rgba(0,0,0,0.1) 44%, rgba(0,0,0,0.86) 100%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 22–28, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · LAST WEEK
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{k}K
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per request</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 30,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>{nf.format(AVG)} tokens / request · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,139 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.72)",
faint: "rgba(255,255,255,0.55)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
const TOKENS = 40_915_594_381 // 40.9B
const SHEEP = 23_600_000 // 23.6M
const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734
const nf = new Intl.NumberFormat("en-US")
// the correct opencode "DATA" wordmark (white, over photo)
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NZSheep() {
const frame = useCurrentFrame()
const count = Math.round(
PER_SHEEP *
interpolate(frame, [18, 90], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
// slow Ken Burns push-in (scale up only — never reveals an edge)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
{/* the sheep, staring */}
<Img
src={staticFile("sheep.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 38%",
transform: `scale(${zoom})`,
transformOrigin: "center 35%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 48%, rgba(0,0,0,0.78) 100%)",
}}
/>
{/* content */}
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 22–28, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · NEW ZEALAND
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{nf.format(count)}
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per sheep</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 34,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>40.9B tokens ÷ 23.6M sheep · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,254 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"
import { days, launchIndex, glmWeekTokensT, segments } from "./data"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#ededed",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const W = 1080
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
const field = segments.filter((s) => !s.hero)
const glmColor = segments.find((s) => s.hero)!.color
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
// the correct opencode "DATA" wordmark (from stats.opencode.ai header)
function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function GLM52Rise() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
// ---------- virtual camera ----------
// open zoomed-in on the field, pan right while the blue fills, then pull back to reveal.
const K = [0, 32, 150, 206, 240]
const ease = Easing.inOut(Easing.cubic)
const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease }
const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt)
let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt)
let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt)
// keep the framing inside the 1080 canvas so edges never reveal black
fx = clamp(fx, 540 / s, W - 540 / s)
fy = clamp(fy, 540 / s, W - 540 / s)
const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})`
// ---------- blue sweep (synced to the pan) ----------
const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease })
const revealAmount = p * (days.length - launchIndex) + 0.35
const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1)
// token number climbs as the camera pulls back and the headline re-enters frame
const tokensT =
glmWeekTokensT *
interpolate(frame, [150, 202], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
})
// chart geometry
const chartH = 440
const chartW = 936
const gap = 14
const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis
return (
<AbsoluteFill style={{ background: c.bg, overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: W,
height: W,
background: c.bg,
transformOrigin: "0 0",
transform: camera,
}}
>
<div
style={{
width: W,
height: W,
boxSizing: "border-box",
padding: 72,
color: c.ink,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 12–25, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 52 }}>
<div style={{ fontSize: 92, fontWeight: 600, lineHeight: 0.98, letterSpacing: -2 }}>
GLM-5.2 <span style={{ color: c.accent }}>broke out</span>
</div>
<div style={{ marginTop: 22, fontSize: 30, fontWeight: 500, color: c.muted }}>
From 0 to <span style={{ color: c.ink }}>{tokensT.toFixed(2)}T tokens</span> in a week.
</div>
</div>
{/* stacked chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 40 }}>
<div
style={{
position: "relative",
width: chartW,
height: chartH,
margin: "0 auto",
borderBottom: `2px solid ${c.ink}`,
boxSizing: "border-box",
}}
>
{/* faint dotted backdrop */}
<div
style={{
position: "absolute",
left: -8,
right: -8,
top: -8,
bottom: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
}}
/>
{/* columns */}
<div style={{ position: "absolute", inset: 0, display: "flex", gap, alignItems: "flex-end" }}>
{days.map((d, i) => {
const glmShare = d.glm / d.total
const blueH = Math.round(glmShare * EXAGGERATE * chartH)
const filled = Math.round(blueH * fillOf(i))
const slotGap = filled > 2 ? 5 : 0
const fieldH = chartH - filled - slotGap
const fieldTotal = d.dsf + d.dsp + d.mm + d.others
return (
<div key={i} style={{ position: "relative", flex: 1, height: chartH }}>
{/* gray field of other models (already in place) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: fieldH,
display: "flex",
flexDirection: "column-reverse",
}}
>
{field.map((seg) => (
<div
key={seg.key}
style={{
height: Math.round(((d[seg.key as keyof typeof d] as number) / fieldTotal) * fieldH),
background: seg.color,
borderTop: `2px solid ${c.bg}`,
}}
/>
))}
</div>
{/* GLM-5.2, animates in */}
{filled > 2 && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: filled,
background: glmColor,
borderTop: `2px solid ${c.accentHi}`,
}}
/>
)}
</div>
)
})}
</div>
</div>
</div>
{/* day axis */}
<div style={{ display: "flex", gap, width: chartW, margin: "14px auto 0" }}>
{days.map((d, i) => (
<div
key={i}
style={{
flex: 1,
textAlign: "center",
fontSize: 15,
fontWeight: 500,
color: i === days.length - 1 ? c.ink : c.faint,
}}
>
{i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""}
</div>
))}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 28,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
GLM-5.2
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}

10
artifacts/glm52-rise-video/sst-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

685
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

@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
// so a regression cannot silently burn money. Stats sync full passes scan
// ~250 GB as of 2026-07.
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},

View file

@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
if ($app.stage === "production") {
new cloudflare.DnsRecord("TrustCenter", {
zoneId: zoneID,
name: "trust.opencode.ai",
type: "CNAME",
content: "3a69a5bb27875189.vercel-dns-016.com",
proxied: false,
ttl: 60,
})
new cloudflare.DnsRecord("TrustCenterVerification", {
zoneId: zoneID,
name: "opencode.ai",
type: "TXT",
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
ttl: 60,
})
}
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
regionKey: "us",

View file

@ -181,17 +181,31 @@ 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",
cpu: "0.25 vCPU",
memory: "0.5 GB",
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
memory: "2 GB",
image: {
context: ".",
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

@ -8,6 +8,8 @@
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
opencode,
}:
let
@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
nodejs
makeWrapper
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
]
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
copyDesktopItems
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
(lib.getLib stdenv.cc.cc)
];
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
name = "ai.opencode.desktop";
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
};
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
postPatch = lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
postPatch =
# NOTE: Relax Bun version check to be a warning instead of an error
''
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
''
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
cp -r "${electron.dist}" $HOME/.electron-dist
@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase =
''
runHook preInstall
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
mkdir -p $out/Applications
mv dist/mac*/*.app $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
''
+ lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
installPhase = ''
runHook preInstall
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
mkdir -p $out/Applications
mv dist/mac*/*.app $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
''
+ lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
install -Dm644 resources/icons/32x32.png \
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/64x64.png \
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128.png \
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128@2x.png \
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/icon.png \
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
autoPatchelfIgnoreMissingDeps = [
"libc.musl-x86_64.so.1"

View file

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
"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",
@ -19,6 +19,7 @@
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@ -39,13 +40,14 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@tanstack/solid-virtual": "3.13.28",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
@ -68,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@ -88,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"
}
@ -104,7 +107,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.8.13"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@ -134,25 +137,33 @@
"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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@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",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.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",
"@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

@ -0,0 +1,61 @@
# Timeline Layout Continuity
Run from `packages/app`:
```sh
bun run test:stability
```
The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
## What It Proves
The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
- Preserve a visible semantic anchor while the user is away from the bottom.
- Preserve end anchoring while active content grows or new content appears.
- Keep adjacent visible rows ordered without material overlap.
- Keep user-selected disclosure state through updates and virtualization.
- Avoid a sampled blank interval while one visible surface replaces another.
- Preserve logical row and control identity where local state or focus depends on it.
- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
## What It Does Not Prove
The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
The suite does not provide complete coverage for:
- Compositor-only or raster-only glitches.
- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
- Physical display refresh rates, native OS scaling, or a named low-end device.
- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
## Test Layers
- **Projection:** admitted rows, grouping, labels, and final visible states.
- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
- **Oracle contract:** pure analyzer and browser sampler calibration tests.
Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
## Diagnostics
Failures retain:
- `video.webm`
- `trace.zip`
- failure screenshot
- sampled DOM/layout trace JSON
- event markers and summarized violations
The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.

View file

@ -0,0 +1,250 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
type TimelineMessage,
} from "./fixture"
test.describe("timeline adverse visual stability", () => {
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
const activeShellID = "prt_adverse_01_shell"
const messages = [
...history(24),
userMessage(),
assistantMessage([shell(activeShellID, "running")], { completed: false }),
]
const timeline = await setupTimeline(page, {
messages,
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
eventRetry: 30,
})
const scroller = page.locator(".scroll-view__viewport", {
has: page.locator('[data-timeline-row="AssistantPart"]'),
})
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
})
await page.waitForTimeout(150)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(100)
const anchor = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
const rect = row.getBoundingClientRect()
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
})?.dataset.timelineKey
})
expect(anchor).toBeTruthy()
await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
const regions = defineVisualRegions({
anchor: { selector: `[data-timeline-key="${anchor}"]` },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"scrolled-away-shell",
trace,
visualPlan(regions, [
{ type: "required", regions: ["anchor"] },
{ type: "unique", regions: ["anchor"] },
{ type: "stable", regions: ["anchor"] },
{ type: "fixed", regions: ["anchor"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
]),
)
})
test("preserves an explicit shell state across virtualization", async ({ page }) => {
const targetID = "prt_virtual_shell"
const messages = [
userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
assistantMessage([shell(targetID, "completed", lines(20))], {
id: "msg_0001_virtual_assistant",
parentID: "msg_0000_virtual_user",
created: 1700000001000,
}),
...history(35, 10),
]
await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
element.scrollTop = 0
})
await page.waitForTimeout(300)
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(trigger).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
})
test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
const shellID = "prt_narrow_01_shell"
const followingID = "prt_narrow_02_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
{
completed: false,
},
),
],
settings: { shellToolPartsExpanded: true },
viewport: { width: 430, height: 800 },
cpuRate: 4,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"narrow-shell",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
),
)
})
test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
const shellID = "prt_resize_01_shell"
const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
const followingID = "prt_resize_04_following"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
shell(shellID, "completed", wideLines(15)),
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
]),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
seedHistory: true,
})
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await page.setViewportSize({ width: 430, height: 800 })
await page.waitForTimeout(500)
await page.setViewportSize({ width: 900, height: 800 })
await page.waitForTimeout(500)
await page.setViewportSize({ width: 1400, height: 900 })
await page.waitForTimeout(500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"responsive-resize",
trace,
visualPlan(regions, [
{ type: "required", regions: ["shell", "context", "following"] },
{ type: "unique", regions: ["shell", "context", "following"] },
{ type: "stable", regions: ["shell", "context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["shell", "context", "following"] },
]),
)
})
})
function history(count: number, offset = 0): TimelineMessage[] {
return Array.from({ length: count }, (_, index) => {
const value = index + offset
const prefix = `msg_0${String(value).padStart(3, "0")}_history`
const userID = `${prefix}_a_user`
return [
userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
assistantMessage(
[
textPart(
`prt_history_${String(value).padStart(3, "0")}`,
`Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
),
],
{
id: `${prefix}_b_assistant`,
parentID: userID,
created: 1699990001000 + value * 10_000,
},
),
]
}).flat()
}
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
function wideLines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
}

View file

@ -0,0 +1,192 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantID,
assistantMessage,
event,
partUpdated,
setupTimeline,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
const inputs = {
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
grep: { path: ".", pattern: "stable", include: "*.ts" },
list: { path: "src" },
}
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
const firstID = "prt_append_01_read"
const followingID = "prt_append_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
completed: false,
}),
],
cpuRate: 4,
})
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
context: {
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
closest: '[data-timeline-row="AssistantPart"]',
},
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-append",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context", "following"] },
{ type: "stable", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["context", "following"] },
],
{ perMarker: true },
),
)
await expect(
page.locator(
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
),
).toBeVisible()
await expect(
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
).toHaveAttribute("aria-expanded", "true")
})
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
const textID = "prt_split_02_text"
const followingID = "prt_split_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart("prt_split_01_read", "read", "completed", inputs.read),
textPart(textID, "Boundary"),
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
textPart(followingID, "Following split groups"),
]),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
500,
)
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-split-merge",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["following"] },
{ type: "unique", regions: ["following"] },
{ type: "stable", regions: ["following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1 },
{ type: "label-stability", regions: "all" },
],
{ perMarker: true },
),
)
})
test("removing the first context member replaces the group once without overlapping following content", async ({
page,
}, testInfo) => {
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
const followingID = "prt_key_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", inputs.read),
toolPart(ids[1]!, "glob", "completed", inputs.glob),
toolPart(ids[2]!, "grep", "completed", inputs.grep),
textPart(followingID, "Following replaced group"),
]),
],
cpuRate: 4,
})
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const originalRowKey = await original.evaluate((element) =>
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
)
await original.locator('[data-slot="collapsible-trigger"]').click()
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
const regions = defineVisualRegions({
context: {
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
closest: '[data-timeline-row="AssistantPart"]',
},
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-first-remove",
trace,
visualPlan(regions, [
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["context", "following"] },
]),
)
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
expect(
await page
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
).toBe(originalRowKey)
await expect(
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
).toHaveAttribute("aria-expanded", "true")
})

View file

@ -0,0 +1,113 @@
import { test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
shell,
textPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
// Fractional scaling exercises different browser rounding than the baseline.
for (const deviceScaleFactor of [1, 1.25]) {
test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
deviceScaleFactor,
seedHistory: true,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
})
}
for (const reducedMotion of [true]) {
test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
page,
}, testInfo) => {
const shellID = `prt_motion_${reducedMotion}_01_shell`
const followingID = `prt_motion_${reducedMotion}_02_following`
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
reducedMotion,
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
})
}
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
regions: Regions & Record<"shell" | "following", { selector: string }>,
) {
return visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
)
}

View file

@ -0,0 +1,121 @@
import { test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{
name: "multi patch",
tool: "apply_patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
for (const profile of profiles) {
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(partID, profile.tool, "pending", profile.input),
textPart(followingID, `Following ${profile.name}`),
],
{ completed: false },
),
],
settings: { editToolPartsExpanded: true },
cpuRate: 4,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
await timeline.send(partUpdated(completedPart(partID, profile)), 900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
`file-${profile.name}`,
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["tool", "following"] },
{ type: "unique", regions: ["tool", "following"] },
{ type: "stable", regions: ["tool", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["tool", "following"] },
],
{ perMarker: true },
),
)
})
}
function completedPart(partID: string, profile: (typeof profiles)[number]) {
if (profile.tool === "edit") {
return toolPart(partID, profile.tool, "completed", profile.input, {
metadata: {
filediff: {
file: "src/edit.ts",
additions: 50,
deletions: 50,
before: source(50, false),
after: source(50, true),
},
},
})
}
const files = [
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
]
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
}
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 20,
deletions: type === "add" ? 0 : 20,
before: type === "add" ? undefined : source(20, false),
after: type === "delete" ? undefined : source(20, true),
}
}
function source(count: number, changed: boolean) {
return Array.from(
{ length: count },
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
).join("")
}

View file

@ -0,0 +1,113 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
const patchID = "prt_incremental_01_patch"
const followingID = "prt_incremental_02_following"
const first = patchFile("src/a.ts", "update")
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"),
],
{ completed: false },
),
],
settings: { editToolPartsExpanded: true },
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "add")
const third = patchFile("src/old.ts", "delete")
await timeline.send(
partUpdated(
toolPart(
patchID,
"apply_patch",
"running",
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
),
),
240,
)
await timeline.send(
partUpdated(
toolPart(
patchID,
"apply_patch",
"completed",
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
),
),
800,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"incremental-patch",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["patch", "following"] },
{ type: "unique", regions: ["patch", "following"] },
{ type: "stable", regions: ["patch", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["patch", "following"] },
],
{ perMarker: true },
),
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
})
function patchFile(filePath: string, type: "add" | "update" | "delete") {
return {
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
}
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}

View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import {
assistantMessage,
event,
toolPart,
userMessage,
validateTimelineEvent,
validateTimelineMessages,
type PartSeed,
} from "./fixture"
describe("timeline fixture validation", () => {
test("accepts a valid timeline", () => {
expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
})
test("rejects malformed SDK values at runtime", () => {
expect(() =>
assistantMessage([], {
error: { name: "APIError", data: { message: "failed" } } as never,
}),
).toThrow()
expect(() =>
validateTimelineEvent({
directory: "C:/OpenCode/TimelineStability",
payload: {
id: "evt_invalid_status",
type: "session.status",
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
},
}),
).toThrow()
})
test("rejects duplicate IDs and orphan assistants", () => {
expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
expect(() =>
validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
).toThrow(/parent user/)
})
test("assigns deterministic event IDs", () => {
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
})
})
if (false) {
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
userMessage([userSeed])
// @ts-expect-error Tool completion fields are not valid while pending.
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
// @ts-expect-error Tool completion fields are not valid while running.
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
// @ts-expect-error Tool error fields are not valid after completion.
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
assistantMessage([
// @ts-expect-error Agent references belong to user messages, not assistant messages.
{ id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
])
// @ts-expect-error Retry status events require message and next.
event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
}

View file

@ -0,0 +1,568 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type {
AssistantMessage,
GlobalEvent,
Message,
Part,
Session,
SessionStatus,
ToolPart,
ToolState,
UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { installSseTransport } from "../../utils/sse-transport"
import { expectSessionTitle } from "../../utils/waits"
export const directory = "C:/OpenCode/TimelineStability"
export const projectID = "proj_timeline_stability"
export const sessionID = "ses_timeline_stability"
export const userID = "msg_1000_timeline_user"
export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type TimelinePayload = Extract<
GlobalEvent["payload"],
{
type:
| "message.updated"
| "message.removed"
| "message.part.updated"
| "message.part.removed"
| "message.part.delta"
| "session.status"
}
>
type DeepReadonly<Value> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
: Value extends object
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
: Value
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
export type EventPayload = TimelineEvent
export type ToolStatus = ToolState["status"]
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
export type PartSeed<Owner extends Message["role"]> =
OwnedPart<Owner> extends infer Candidate
? Candidate extends Part
? Omit<Candidate, "sessionID" | "messageID">
: never
: never
type ToolOptions<State extends ToolStatus> = State extends "pending"
? { output?: never; title?: never; metadata?: never; error?: never }
: State extends "running"
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
: State extends "error"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
const timelineEventSchema = Schema.Union([
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
eventSchema("session.status", SessionStatusEvent.Status.data),
])
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
let eventSequence = 0
export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
eventRetry?: number
reducedMotion?: boolean
locale?: string
deviceScaleFactor?: number
seedHistory?: boolean
protocol?: "v1" | "v2"
} = {},
) {
const sessions = input.sessions ?? [session()]
const messages = validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) => message.info.role === "assistant")
const initialStatus = decodeStatus(
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions,
)
const transport = await installSseTransport<EventPayload>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: input.protocol,
directory,
project: project(),
provider: provider(),
sessions,
sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
})
await page.addInitScript((settings) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: false,
shellToolPartsExpanded: false,
showReasoningSummaries: false,
showSessionProgressBar: true,
...settings,
},
}),
)
if (settings.newLayoutDesigns === false) {
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
}
}, input.settings ?? {})
if (input.locale) {
await page.addInitScript((locale) => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
}, input.locale)
}
if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
if (input.deviceScaleFactor) {
const devtools = await page.context().newCDPSession(page)
const viewport = input.viewport ?? { width: 1400, height: 900 }
await devtools.send("Emulation.setDeviceMetricsOverride", {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: input.deviceScaleFactor,
mobile: false,
})
}
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
if (input.cpuRate && input.cpuRate > 1) {
const devtools = await page.context().newCDPSession(page)
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
}
return {
transport,
async send(event: TimelineEvent, delay = 0) {
const valid = validateTimelineEvent(event)
await transport.send(valid, { marker: describeEvent(valid) })
if (delay) await page.waitForTimeout(delay)
},
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
for (const item of sequence) {
const valid = validateTimelineEvent(item.event)
await transport.send(valid, { marker: describeEvent(valid) })
await page.waitForTimeout(item.delay)
}
},
async settle(frames = 3) {
await page.evaluate(
(frames) =>
new Promise<void>((resolve) => {
let remaining = frames
const tick = () => {
remaining--
if (remaining <= 0) return resolve()
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}),
frames,
)
},
async waitForPart(partID: string) {
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
},
}
}
function describeEvent(event: EventPayload) {
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return [
event.payload.type,
part.id,
part.type === "tool" ? part.tool : part.type,
part.type === "tool" ? part.state.status : undefined,
]
.filter(Boolean)
.join(":")
}
if (event.payload.type === "session.status") {
const status = event.payload.properties.status
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
.filter((value) => value !== undefined)
.join(":")
}
return event.payload.type
}
export function event<const Type extends TimelinePayload["type"]>(
type: Type,
properties: Extract<TimelinePayload, { type: Type }>["properties"],
): TimelineEvent
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
return validateTimelineEvent({
directory,
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
})
}
export function validateTimelineEvent(input: unknown): TimelineEvent {
return decodeEvent(input, decodeOptions)
}
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
input.forEach((message) => decodeMessage(message, decodeOptions))
const messages = [...input]
const messageIDs = new Set<string>()
const partIDs = new Set<string>()
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
messages.forEach((message) => {
if (messageIDs.has(message.info.id))
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
messageIDs.add(message.info.id)
if (message.info.role === "assistant" && !users.has(message.info.parentID))
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
message.parts.forEach((part) => {
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
partIDs.add(part.id)
})
})
return messages
}
export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
await page.waitForFunction(
({ selectors, stableFrames }) => {
const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
if (elements.some((element) => !element)) return false
return new Promise<boolean>((resolve) => {
let stable = 0
let previous = ""
const sample = () => {
const signature = JSON.stringify(
elements.map((element) => {
const rect = element!.getBoundingClientRect()
return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
}),
)
stable = signature === previous ? stable + 1 : 0
previous = signature
const ordered = elements
.slice(1)
.every(
(element, index) =>
elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
)
if (stable >= stableFrames && ordered) return resolve(true)
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
},
{ selectors, stableFrames },
)
}
export function historyMessages(count: number): TimelineMessage[] {
return Array.from({ length: count }, (_, index) => {
const value = String(index).padStart(4, "0")
const historyUserID = `msg_0${value}_history_a_user`
return [
userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
assistantMessage(
[
{
id: `prt_0${value}_history_text`,
type: "text",
text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
},
],
{
id: `msg_0${value}_history_b_assistant`,
parentID: historyUserID,
created: 1690000001000 + index * 10_000,
},
),
]
}).flat()
}
export function partUpdated(part: Part | PartSeed<"assistant">) {
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
decodePart(owned, decodeOptions)
return event("message.part.updated", {
sessionID,
part: owned,
time: 1700000002000,
})
}
export function partDelta(partID: string, delta: string, messageID = assistantID) {
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
}
export function messageUpdated(info: Message) {
return event("message.updated", { sessionID, info })
}
export function status(type: SessionStatus["type"], attempt = 1) {
return event("session.status", {
sessionID,
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
})
}
export function userMessage(
parts?: PartSeed<"user">[],
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
): Extract<TimelineMessage, { info: { role: "user" } }> {
const id = input.id ?? userID
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
const message = {
info: {
id,
sessionID,
role: "user",
time: { created: input.created ?? 1700000000000 },
summary: input.summary ?? { diffs: [] },
agent: "build",
model,
},
parts: seeds.map((part) => ({
...part,
sessionID,
messageID: id,
})),
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
decodeMessage(message, decodeOptions)
return message
}
export function assistantMessage(
parts: PartSeed<"assistant">[] = [],
input: {
id?: string
parentID?: string
completed?: boolean
error?: AssistantMessage["error"]
created?: number
} = {},
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
const id = input.id ?? assistantID
const message = {
info: {
id,
sessionID,
role: "assistant",
time: {
created: input.created ?? 1700000001000,
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
},
parentID: input.parentID ?? userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
...(input.error ? { error: input.error } : {}),
},
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
decodeMessage(message, decodeOptions)
return message
}
export function userText(
text: string,
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
): Extract<PartSeed<"user">, { type: "text" }> {
return { id: "prt_user_text", type: "text", text, ...input }
}
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
return { id, type: "text", text }
}
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
}
export function toolPart(
id: string,
tool: string,
state: "pending",
input: Record<string, unknown>,
options?: ToolOptions<"pending">,
): Omit<ToolPart, "sessionID" | "messageID">
export function toolPart(
id: string,
tool: string,
state: "running",
input: Record<string, unknown>,
options?: ToolOptions<"running">,
): Omit<ToolPart, "sessionID" | "messageID">
export function toolPart(
id: string,
tool: string,
state: "completed",
input: Record<string, unknown>,
options?: ToolOptions<"completed">,
): Omit<ToolPart, "sessionID" | "messageID">
export function toolPart(
id: string,
tool: string,
state: "error",
input: Record<string, unknown>,
options?: ToolOptions<"error">,
): Omit<ToolPart, "sessionID" | "messageID">
export function toolPart(
id: string,
tool: string,
state: ToolStatus,
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> {
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running")
return {
...base,
state: {
status: state,
input,
title: options.title,
metadata: options.metadata ?? {},
time: { start: 1700000001000 },
},
}
if (state === "error")
return {
...base,
state: {
status: state,
input,
error: options.error ?? "Tool failed",
metadata: options.metadata ?? {},
time: { start: 1700000001000, end: 1700000002000 },
},
}
return {
...base,
state: {
status: state,
input,
output: options.output ?? "Completed",
title: options.title ?? tool,
metadata: options.metadata ?? {},
time: { start: 1700000001000, end: 1700000002000 },
},
}
}
export function shell(
id: string,
state: ToolStatus,
output = "",
command = `echo ${id}`,
): Omit<ToolPart, "sessionID" | "messageID"> {
if (state === "pending") return toolPart(id, "bash", state, { command })
if (state === "running")
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
if (state === "error")
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
}
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
return { ...info, time: { ...info.time, completed: 1700000003000 } }
}
export function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-stability",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
export function session(input: Partial<Session> = {}): Session {
return {
id: sessionID,
slug: "timeline-stability",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
...input,
}
}
function eventSchema<
const Type extends TimelinePayload["type"],
const Properties extends Schema.Codec<unknown, unknown>,
>(type: Type, properties: Properties) {
return Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
})
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}

View file

@ -0,0 +1,242 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
const shellID = "prt_interaction_01_shell"
const followingID = "prt_interaction_02_following"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
const plan = visualPlan(regions, [
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
])
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await page.waitForTimeout(500)
const expanded = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await page.waitForTimeout(500)
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
})
test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
const ids = [
"prt_interaction_01_read",
"prt_interaction_02_glob",
"prt_interaction_03_grep",
"prt_interaction_04_list",
]
const group = `[data-timeline-part-ids="${ids.join(",")}"]`
const followingID = "prt_interaction_context_following"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
toolPart(ids[3]!, "list", "completed", { path: "src" }),
textPart(followingID, "Following context expansion"),
]),
],
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
for (const [name, expanded] of [
["context-expand", true],
["context-collapse", false],
["context-reexpand", true],
] as const) {
const regions = defineVisualRegions({
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await page.waitForTimeout(500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
name,
trace,
visualPlan(regions, [
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context", "following"] },
{ type: "stable", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["context", "following"] },
]),
)
}
})
test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
const editID = "prt_interaction_edit"
const followingID = "prt_interaction_edit_following"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
editID,
"edit",
"completed",
{ filePath: "src/edit.ts" },
{
metadata: {
filediff: {
file: "src/edit.ts",
additions: 40,
deletions: 40,
before: source(40, false),
after: source(40, true),
},
},
},
),
textPart(followingID, "Following edit expansion"),
]),
],
settings: { editToolPartsExpanded: false },
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"edit-expand",
trace,
visualPlan(regions, [
{ type: "required", regions: ["edit", "following"] },
{ type: "unique", regions: ["edit", "following"] },
{ type: "stable", regions: ["edit", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["edit", "following"] },
]),
)
})
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
const firstUser = userMessage(undefined, {
summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
})),
},
})
const nextUserID = "msg_2000_diff_interaction_user"
await setupTimeline(page, {
messages: [
firstUser,
assistantMessage(),
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_2001_diff_interaction_assistant",
parentID: nextUserID,
created: 1700000011000,
}),
],
cpuRate: 4,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
const diff = page.locator('[data-timeline-row="DiffSummary"]')
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
await expect(diff).toBeVisible()
const regions = defineVisualRegions({
diff: { selector: '[data-timeline-row="DiffSummary"]' },
following: { selector: `[data-message-id="${nextUserID}"]` },
})
await startVisualProbe(page, regions)
await page.getByText(/show all/i).click()
await page.waitForTimeout(500)
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"diff-summary-expand",
trace,
visualPlan(regions, [
{ type: "required", regions: ["diff", "following"] },
{ type: "unique", regions: ["diff", "following"] },
{ type: "stable", regions: ["diff", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["diff", "following"] },
]),
)
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
function source(count: number, changed: boolean) {
return Array.from(
{ length: count },
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
).join("")
}

View file

@ -0,0 +1,157 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
mapVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
completedAssistantInfo,
messageUpdated,
partDelta,
partUpdated,
reasoningPart,
setupTimeline,
shell,
status,
textPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
test.describe("timeline visual lifecycle stability", () => {
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
test.setTimeout(180_000)
const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
const initial = ids.map((id) => shell(id, "running"))
const followingID = "prt_parallel_04_following"
const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
completed: false,
})
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
cpuRate: 4,
eventRetry: 24,
seedHistory: true,
})
await timeline.send(status("busy"), 150)
for (const id of ids) await timeline.waitForPart(id)
const scroller = page.locator(".scroll-view__viewport", {
has: page.locator('[data-timeline-row="AssistantPart"]'),
})
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
const regions = defineVisualRegions({
prt_shell_empty: shellRegion(ids[0]),
prt_shell_short: shellRegion(ids[1]),
prt_shell_long: shellRegion(ids[2]),
following: shellRegion(followingID),
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
await startVisualProbe(page, regions)
await timeline.sendAll([
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
{ event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
{ event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
{ event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
{ event: status("idle"), delay: 700 },
])
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"parallel-shells",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
{ type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
{ type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
],
{ perMarker: true },
),
)
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
await short.locator('[data-slot="collapsible-trigger"]').click()
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
})
test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
page,
}, testInfo) => {
const reasoningID = "prt_reasoning_visible"
const textID = "prt_streamed_text"
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: true },
cpuRate: 4,
})
await timeline.send(status("busy"), 120)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
const regions = defineVisualRegions({
thinking: { selector: '[data-timeline-row="Thinking"]' },
reasoning: {
selector: `[data-timeline-part-id="${reasoningID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
await timeline.waitForPart(reasoningID)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
await timeline.send(partDelta(textID, " **stable"), 90)
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
await timeline.send(status("idle"), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"reasoning-text-handoff",
trace,
visualPlan(regions, [
{ type: "required", regions: ["reasoning", "text"] },
{ type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
{ type: "unique", regions: ["reasoning", "text"] },
{ type: "stable", regions: ["reasoning", "text"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxReversals: 4 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["reasoning", "text"] },
]),
)
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
})
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
function shellRegion(id: string) {
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
}

View file

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test"
import {
analyzeVisualObservations,
defineVisualRegions,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
test("detects blanking caused by ancestor opacity", async ({ page }) => {
const partID = "prt_oracle_ancestor_opacity"
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
const regions = defineVisualRegions({
content: { selector: `[data-timeline-part-id="${partID}"]` },
})
await startVisualProbe(page, regions)
await row.evaluate((element) => {
element.parentElement!.style.opacity = "0"
})
await page.waitForTimeout(50)
await row.evaluate((element) => {
element.parentElement!.style.opacity = "1"
})
await page.waitForTimeout(50)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const issues = analyzeVisualObservations(
trace.samples,
visualPlan(regions, [
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all" },
{ type: "label-stability", regions: "all" },
]),
)
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
})
test("detects root opacity when probing descendant opacity", async ({ page }) => {
const partID = "prt_oracle_descendant_opacity"
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
await row.evaluate((element) => {
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
})
const regions = defineVisualRegions({
content: {
selector: `[data-timeline-part-id="${partID}"]`,
opacitySelectors: ['[data-probe-opacity="true"]'],
},
})
await startVisualProbe(page, regions)
await row.evaluate((element) => {
;(element as HTMLElement).style.opacity = "0"
})
await page.waitForTimeout(50)
await row.evaluate((element) => {
;(element as HTMLElement).style.opacity = "1"
})
await page.waitForTimeout(50)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const issues = analyzeVisualObservations(
trace.samples,
visualPlan(regions, [
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all" },
{ type: "label-stability", regions: "all" },
]),
)
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
})

View file

@ -0,0 +1,17 @@
import config from "../playwright.config"
export default {
...config,
testDir: ".",
testMatch: "**/*.spec.ts",
outputDir: "../../test-results/timeline-stability",
reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
retries: 0,
workers: 1,
use: {
...config.use,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
}

View file

@ -0,0 +1,353 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
shell,
textPart,
userMessage,
type TimelineMessage,
} from "./fixture"
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_wheel_01_shell"
const followingID = "prt_wheel_02_following"
const timeline = await setupTimeline(page, {
messages: [
...history(12),
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
reducedMotion: true,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
await scroller.evaluate((element) =>
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
)
await scroller.evaluate((element) => (element.scrollTop -= 180))
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
})
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.getByText("History 79.", { exact: false })
await expect(text).toBeVisible()
await scroller.evaluate((element) => {
element.dataset.selectionLength = "0"
document.addEventListener("selectionchange", () => {
element.dataset.selectionLength = String(
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
)
})
})
const textBox = await text.boundingBox()
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(500)
await page.mouse.up()
})
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_keyboard_01_shell"
const followingID = "prt_keyboard_02_following"
const timeline = await setupTimeline(page, {
messages: [
...history(12),
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.focus()
for (let index = 0; index < 3; index++) {
await scroller.press("PageUp")
await page.waitForTimeout(250)
}
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
timeout: 20_000,
})
.toBeGreaterThan(80)
await page.waitForFunction(() => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!root) return false
return new Promise<boolean>((resolve) => {
const top = root.scrollTop
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
})
})
const anchor = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
const rect = row.getBoundingClientRect()
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
})?.dataset.timelineKey
})
expect(anchor).toBeTruthy()
const regions = defineVisualRegions({
anchor: { selector: `[data-timeline-key="${anchor}"]` },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
await row.focus()
for (let index = 0; index < 3; index++) {
await row.press("PageUp")
await page.waitForTimeout(250)
}
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(5)
const anchor = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
const rect = row.getBoundingClientRect()
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
})?.dataset.timelineKey
})
expect(anchor).toBeTruthy()
const regions = defineVisualRegions({
anchor: { selector: `[data-timeline-key="${anchor}"]` },
})
await startVisualProbe(page, regions)
await trigger.click()
await page.waitForTimeout(300)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
})
test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
const shellID = "prt_nested_keyboard_shell"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
reducedMotion: true,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
await nested.focus()
await page.waitForFunction(() => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!root) return false
return new Promise<boolean>((resolve) => {
const top = root.scrollTop
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
})
})
const before = await scroller.evaluate((element) => element.scrollTop)
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
await nested.press("PageUp")
await page.waitForTimeout(300)
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await nested.evaluate((element) => (element.scrollTop = 0))
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
expect(boundaryBefore).toBeGreaterThan(0)
await nested.press("PageUp")
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
await nonOverflowing.evaluate((element) => {
element.setAttribute("data-scrollable", "")
element.setAttribute("tabindex", "0")
})
await nonOverflowing.focus()
const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
await nonOverflowing.press("PageUp")
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
})
test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
const shellID = "prt_jump_01_shell"
const followingID = "prt_jump_02_following"
const timeline = await setupTimeline(page, {
messages: [
...history(20),
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate(
(element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
)
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await page.getByRole("button", { name: /Jump to latest/i }).click()
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
await page.waitForTimeout(600)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"jump-latest",
trace,
visualPlan(regions, [
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1 },
{ type: "label-stability", regions: "all" },
{ type: "acquire-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
]),
)
})
test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
const shellID = "prt_tall_01_shell"
const followingID = "prt_tall_02_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
],
settings: { shellToolPartsExpanded: true },
viewport: { width: 900, height: 360 },
cpuRate: 4,
seedHistory: true,
})
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"taller-than-viewport",
trace,
visualPlan(regions, [
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
]),
)
})
function history(count: number): TimelineMessage[] {
return Array.from({ length: count }, (_, index) => {
const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
const userID = `${prefix}_a_user`
return [
userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
assistantMessage(
[textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
{
id: `${prefix}_b_assistant`,
parentID: userID,
created: 1690000001000 + index * 10_000,
},
),
]
}).flat()
}
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
function rowPairPlan(
regions: Record<"shell" | "following", { selector: string; closest?: string }>,
maxPositionReversals: number,
) {
return visualPlan(regions, [
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["shell", "following"] },
])
}
function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
return visualPlan(regions, [
{ type: "required", regions: ["anchor"] },
{ type: "unique", regions: ["anchor"] },
{ type: "stable", regions: ["anchor"] },
{ type: "fixed", regions: ["anchor"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
])
}

View file

@ -0,0 +1,255 @@
import { test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
setupTimeline,
shell,
textPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
const profiles = [
{
name: "empty running to completed",
updates: [{ state: "completed" as const, output: "", delay: 350 }],
},
{
name: "50 lines arriving incrementally",
updates: [
{ state: "running" as const, output: lines(1), delay: 100 },
{ state: "running" as const, output: lines(10), delay: 160 },
{ state: "running" as const, output: lines(25), delay: 90 },
{ state: "running" as const, output: lines(50), delay: 220 },
{ state: "completed" as const, output: lines(50), delay: 500 },
],
},
{
name: "wide ANSI and CRLF output",
updates: [
{
state: "running" as const,
output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
"\r\n",
),
delay: 240,
},
{
state: "completed" as const,
output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
delay: 500,
},
],
},
] as const
for (const profile of profiles) {
test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
for (const update of profile.updates) {
await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
}
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
`shell-${profiles.indexOf(profile)}`,
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
),
)
})
}
test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
const shellID = "prt_matrix_collapsed_01_shell"
const followingID = "prt_matrix_collapsed_02_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: false },
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"collapsed-shell",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
),
)
})
test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
const shellID = "prt_matrix_error_01_shell"
const followingID = "prt_matrix_error_02_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
completed: false,
}),
],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated({
...shell(shellID, "error"),
state: {
status: "error",
input: { command: `echo ${shellID}` },
error: "Command failed after output",
metadata: {},
time: { start: 1700000001000, end: 1700000002000 },
},
}),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"shell-error",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
),
)
})
test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
const shellID = "prt_late_text_01_shell"
const followingID = "prt_late_text_02_following"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
settings: { shellToolPartsExpanded: true },
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"late-text-before-shell-output",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["shell", "following"] },
{ type: "unique", regions: ["shell", "following"] },
{ type: "stable", regions: ["shell"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["shell", "following"] },
],
{ perMarker: true },
),
)
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}

View file

@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
partUpdated,
session,
sessionID,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "./fixture"
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
const taskID = "prt_task_link"
const childID = "ses_task_child"
const input = { description: "Inspect child", subagent_type: "explore" }
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
cpuRate: 4,
})
const regions = defineVisualRegions({
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"task-link",
trace,
visualPlan(regions, [
{ type: "required", regions: ["task"] },
{ type: "unique", regions: ["task"] },
{ type: "stable", regions: ["task"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
]),
)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
})
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
const toolID = "prt_generic_mutation"
const followingID = "prt_generic_mutation_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
textPart(followingID, "Following generic tool"),
],
{ completed: false },
),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
200,
)
await timeline.send(
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
400,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"generic-mutation",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["tool", "following"] },
{ type: "unique", regions: ["tool", "following"] },
{ type: "stable", regions: ["tool", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["tool", "following"] },
],
{ perMarker: true },
),
)
})

View file

@ -0,0 +1,198 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantMessage,
directory,
partUpdated,
session,
sessionID,
setupTimeline,
status,
textPart,
toolPart,
userMessage,
} from "./fixture"
test.describe("timeline tool state stability", () => {
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
page,
}, testInfo) => {
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
const inputs = {
webfetch: { url: "https://example.com/docs" },
websearch: { query: "timeline stability" },
task: { description: "Inspect timeline", subagent_type: "explore" },
skill: { name: "stability" },
custom: { target: "timeline", depth: 2 },
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage(initial, { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
cpuRate: 4,
})
await timeline.send(status("busy"), 120)
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
const regionIDs = [
"prt_state_webfetch",
"prt_state_websearch",
"prt_state_task",
"prt_state_skill",
"prt_state_custom",
] as const
const regions = defineVisualRegions({
prt_state_webfetch: toolRegion(regionIDs[0]),
prt_state_websearch: toolRegion(regionIDs[1]),
prt_state_task: toolRegion(regionIDs[2]),
prt_state_skill: toolRegion(regionIDs[3]),
prt_state_custom: toolRegion(regionIDs[4]),
})
await startVisualProbe(page, regions)
for (const [index, id] of ids.entries()) {
await timeline.send(
partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
[80, 240, 100, 360, 140][index],
)
}
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
const key = id as (typeof ids)[number]
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
await timeline.send(
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
[110, 70, 280, 130, 420][index],
)
}
await timeline.send(
partUpdated(
toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
),
350,
)
await timeline.waitForPart(questionID)
await timeline.send(status("idle"), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"lightweight-tools",
trace,
visualPlan(regions, [
{ type: "required", regions: regionIDs },
{ type: "unique", regions: regionIDs },
{ type: "stable", regions: regionIDs },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxReversals: 4 },
{ type: "label-stability", regions: "all" },
]),
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
})
test("keeps an expanded mixed context group stable through staggered completion and error", async ({
page,
}, testInfo) => {
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
const tools = ["read", "glob", "grep", "list"]
const inputs = [
{ filePath: "src/a.ts", offset: 0, limit: 120 },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "stability", include: "*.ts" },
{ path: "src" },
]
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
],
cpuRate: 4,
})
await timeline.send(status("busy"), 100)
const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
const group = page.locator(groupSelector)
await expect(group).toBeVisible()
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
const regions = defineVisualRegions({
status: {
selector: `${groupSelector} [data-component="tool-status-title"]`,
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
},
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: '[data-timeline-part-id="prt_ctx_following"]',
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
for (const [index, delay] of [90, 260, 70, 380].entries()) {
await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
}
await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
await timeline.send(
partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
110,
)
await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
await timeline.send(status("idle"), 700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"mixed-context",
trace,
visualPlan(regions, [
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context"] },
{ type: "stable", regions: ["context"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxReversals: 4 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["context", "following"] },
]),
)
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
})
})
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
function toolRegion(id: string) {
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
}

View file

@ -0,0 +1,272 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
event,
messageUpdated,
partDelta,
partUpdated,
setupTimeline,
shell,
status,
textPart,
toolPart,
userMessage,
} from "./fixture"
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
const firstID = "prt_mutation_01_first"
const middleID = "prt_mutation_02_middle"
const lastID = "prt_mutation_03_last"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
completed: false,
}),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
500,
)
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
})
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
const textID = "prt_text_reconcile"
const followingID = "prt_text_reconcile_following"
const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
completed: false,
})
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
const regions = defineVisualRegions({
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partDelta(textID, " streamed content"), 100)
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"text-reconcile",
trace,
visualPlan(regions, [
{ type: "required", regions: ["text", "following"] },
{ type: "unique", regions: ["text", "following"] },
{ type: "stable", regions: ["text", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["text", "following"] },
]),
)
})
test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
const firstID = "prt_question_01_first"
const questionID = "prt_question_02_hidden"
const lastID = "prt_question_03_last"
const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
textPart(firstID, "Before question"),
toolPart(questionID, "question", "running", input),
textPart(lastID, "After question"),
],
{ completed: false },
),
],
cpuRate: 4,
})
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
const regions = defineVisualRegions({
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
600,
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
})
test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
const regions = defineVisualRegions({
thinking: { selector: '[data-timeline-row="Thinking"]' },
error: { selector: '[data-timeline-row="Error"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
messageUpdated({
...assistant.info,
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
}),
500,
)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"thinking-error",
trace,
visualPlan(regions, [
{ type: "required", regions: ["thinking", "error"] },
{ type: "continuous-any", regions: ["thinking", "error"] },
{ type: "unique", regions: ["thinking", "error"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all" },
{ type: "label-stability", regions: "all" },
]),
)
})
test("updates retry attempts and long provider messages without remounting the retry row", async ({
page,
}, testInfo) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
cpuRate: 4,
})
await timeline.send(status("retry", 1), 120)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
const regions = defineVisualRegions({
retry: { selector: '[data-timeline-row="Retry"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
event("session.status", {
sessionID: "ses_timeline_stability",
status: {
type: "retry",
attempt: 2,
message: "A very long provider retry message ".repeat(8),
next: Date.now() + 10_000,
},
}),
300,
)
await timeline.send(status("retry", 3), 300)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"retry-evolution",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["retry"] },
{ type: "unique", regions: ["retry"] },
{ type: "stable", regions: ["retry"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
],
{ perMarker: true },
),
)
})
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
page,
}, testInfo) => {
const removeUserID = "msg_0500_remove_user"
const removeAssistantID = "msg_0501_remove_assistant"
const anchorUserID = "msg_2000_anchor_user"
const timeline = await setupTimeline(page, {
messages: [
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
id: removeAssistantID,
parentID: removeUserID,
created: 1690000001000,
}),
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
id: "msg_2001_anchor_assistant",
parentID: anchorUserID,
created: 1700000001000,
}),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
200,
)
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"historical-turn-remove",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["anchor"] },
{ type: "unique", regions: ["anchor"] },
{ type: "stable", regions: ["anchor"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
],
{ perMarker: true },
),
)
})
function stablePairPlan(
regions: Record<"first" | "last", { selector: string; closest?: string }>,
maxPositionReversals: number,
) {
return visualPlan(regions, [
{ type: "required", regions: ["first", "last"] },
{ type: "unique", regions: ["first", "last"] },
{ type: "stable", regions: ["first", "last"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals },
{ type: "label-stability", regions: "all" },
])
}

View file

@ -0,0 +1,312 @@
import type { Page } from "@playwright/test"
import { benchmark, expect } from "../benchmark"
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
const changedLinesPerFile = 100
const linesPerSide = changedLinesPerFile / 2
const fileCounts = [1, 10, 100, 1_000, 10_000]
const filesPerDirectory = 100
const readyFrames = 3
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
type ReviewPaneScalingSample = {
observedAtMs: number
logicalRows: number
treeRows: number
fileRows: number
diffLines: number
header: string
ready: boolean
}
type ReviewPaneScalingProbe = {
startedAt?: number
firstTreeRowMs?: number
logicalTreeReadyMs?: number
firstDiffRenderMs?: number
stableReadyMs?: number
samples: ReviewPaneScalingSample[]
frameTimesMs: number[]
longTasks: { startTime: number; duration: number }[]
stop: () => void
}
benchmark.describe("performance: review pane scaling", () => {
for (const fileCount of fileCounts) {
const changedLines = fileCount * changedLinesPerFile
benchmark(
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
async ({ page, report }) => {
benchmark.setTimeout(1_200_000)
await page.emulateMedia({ reducedMotion: "reduce" })
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
await setupTimelineBenchmark(page, {
historyTurns: 0,
eventBatch: 1,
newLayoutDesigns: true,
})
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: responseBody,
}),
)
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
const metrics = await measureReviewPaneLoad(page, {
expectedFile: reviewFile(0),
expectedRows,
})
const search = await measureBroadReviewSearch(page, fileCount)
expect(metrics.logicalRows).toBe(expectedRows)
expect(metrics.fileRows).toBeGreaterThan(0)
expect(metrics.treeRows).toBeGreaterThan(0)
expect(metrics.diffLines).toBeGreaterThan(0)
expect(search.logicalRows).toBe(fileCount)
expect(search.renderedRows).toBeGreaterThan(0)
report(
{ ...metrics, search },
{
fileCount,
changedLinesPerFile,
changedLines,
additions: changedLines / 2,
deletions: changedLines / 2,
patchLines: changedLines,
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
expectedRows,
},
)
},
)
}
})
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.evaluate((element) => {
element.addEventListener(
"input",
() => {
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
},
{ once: true, capture: true },
)
})
await filter.fill("file-")
return page.evaluate((expectedRows) => {
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
let previous = -1
let streak = 0
const sample = () => {
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
previous = rows.length
if (streak >= 3) {
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
}, expectedRows)
}
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
const changes = Array.from({ length: linesPerSide }, (_, index) => {
const line = String(index).padStart(3, "0")
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
}).join("\n")
let patchBytes = 0
let capped = false
return Array.from({ length: fileCount }, (_, index) => {
const file = reviewFile(index)
const fullPatch = [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
changes,
].join("\n")
if (index === 0 && fullPatch.length > patchByteLimit)
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
if (patch === fullPatch) patchBytes += fullPatch.length
else capped = true
return {
file,
patch,
additions: linesPerSide,
deletions: linesPerSide,
status: "modified" as const,
}
})
}
function emptyReviewPatch(file: string) {
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
}
function reviewFile(index: number) {
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
}
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
const toggle = page.getByRole("button", { name: "Toggle review" })
await expect(toggle).toBeVisible()
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
await installReviewPaneScalingProbe(page, input)
await toggle.click()
await page.waitForFunction(
() =>
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
?.stableReadyMs !== undefined,
undefined,
{ timeout: completionTimeoutMs },
)
return page.evaluate(() => {
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
probe.stop()
const startedAt = probe.startedAt!
const final = probe.samples.at(-1)!
const resources = performance
.getEntriesByType("resource")
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
const resource = resources.at(-1)
const longTasks = probe.longTasks.filter(
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
)
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
return {
firstTreeRowMs: probe.firstTreeRowMs ?? null,
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
stableReadyMs: probe.stableReadyMs ?? null,
responseStartMs: resource ? resource.responseStart - startedAt : null,
responseEndMs: resource ? resource.responseEnd - startedAt : null,
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
treeRows: final.treeRows,
logicalRows: final.logicalRows,
fileRows: final.fileRows,
diffLines: final.diffLines,
samples: probe.samples.length,
maxFrameGapMs: Math.max(0, ...frameGaps),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
}
})
}
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
await page.evaluate(
({ expectedFile, expectedRows, stableFrames }) => {
let running = true
let readyStreak = 0
const basename = expectedFile.split("/").at(-1)!
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
? new PerformanceObserver((list) => {
probe.longTasks.push(
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
)
})
: undefined
const probe: ReviewPaneScalingProbe = {
samples: [],
frameTimesMs: [],
longTasks: [],
stop: () => {
running = false
longTaskObserver?.disconnect()
},
}
const sample = (time: number) => {
if (!running || probe.startedAt === undefined) return
const panel = document.querySelector<HTMLElement>("#review-panel")
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
const header =
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
const viewers = panel
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
: []
const diffLines = viewers.reduce(
(sum, viewer) =>
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
0,
)
const observedAtMs = time - probe.startedAt
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows &&
fileRows.length > 0 &&
header.includes(basename) &&
viewers.length === 1 &&
diffLines > 0
const previous = probe.samples.at(-1)
const stable =
ready &&
previous?.ready === true &&
previous.logicalRows === logicalRows &&
previous.treeRows === rows.length &&
previous.fileRows === fileRows.length &&
previous.diffLines === diffLines &&
previous.header === header
probe.frameTimesMs.push(observedAtMs)
probe.samples.push({
observedAtMs,
logicalRows,
treeRows: rows.length,
fileRows: fileRows.length,
diffLines,
header,
ready,
})
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
probe.logicalTreeReadyMs = observedAtMs
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
}
longTaskObserver?.observe({ type: "longtask", buffered: true })
document.addEventListener(
"click",
(event) => {
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
probe.startedAt = performance.now()
performance.mark("opencode.review-pane-scaling.click")
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
},
{ ...input, stableFrames: readyFrames },
)
}

View file

@ -0,0 +1,172 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type ParentHydrationBenchmarkMode = "natural" | "candidate"
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
const userID = "msg_parent_hydration_user"
const user = {
...fixture.messages[fixture.targetID][0]!,
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
...part,
id: `prt_parent_hydration_user_${index}`,
messageID: userID,
})),
}
const assistantSeed = fixture.messages[fixture.targetID][3]!
const assistants = Array.from({ length: 14 }, (_, index) => {
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
return {
...assistantSeed,
info: {
...assistantSeed.info,
id: messageID,
parentID: userID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
},
parts: assistantSeed.parts.map((part, partIndex) => ({
...part,
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
messageID,
})),
}
})
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
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)
const results = [] as Awaited<ReturnType<typeof trial>>[]
for (let run = 0; run < 5; run++) {
results.push(
await withBenchmarkPage(
browser,
`session-parent-hydration-${mode}-${run}`,
(page) => trial(page, mode),
testInfo,
),
)
}
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
report(
{
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
summary: {
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
blankSamples: results.map((result) => result.metrics.blankSamples),
requestCounts: {
list: results.map((result) => result.requestCounts.list),
parent: results.map((result) => result.requestCounts.parent),
},
historyGateCount: results.map((result) => result.historyGateCount),
},
},
{ mode },
)
})
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
const requests: { type: "list" | "parent"; before?: string }[] = []
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
let historyGates = 0
await mockOpenCodeServer(page, {
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
messageDelay: 50,
onMessages: (request) => {
if (request.sessionID === fixture.targetID && request.phase === "start")
requests.push({ type: "list", before: request.before })
},
beforeMessagesResponse: (request) => {
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
historyGates++
return history!.promise
},
onMessage: (request) => {
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
},
message: (sessionID, messageID) => {
if (sessionID !== fixture.targetID || messageID !== userID) return
return user
},
pageMessages: (sessionID, limit, before) => {
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
const start = Math.max(0, end - limit)
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
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)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const href = stressSessionHref(fixture.targetID)
await page.evaluate(
({ href, title }) => {
const link = document.createElement("a")
link.id = "parent-hydration-target"
link.href = href
link.textContent = title
document.body.append(link)
},
{ href, title: target.title },
)
const metrics = await measureSessionSwitch(page, {
destinationIDs: messages.map((message) => message.info.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
lastID,
requiredPartID: lastPartID,
requireBottomAnchor: false,
href,
switch: async () => {
await page.locator("#parent-hydration-target").click()
await expectSessionTitle(page, target.title)
},
}).finally(() => history?.resolve())
expect(metrics.firstCorrectObservedMs).not.toBeNull()
const requestCounts = {
list: requests.filter((request) => request.type === "list").length,
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}

View file

@ -2,7 +2,13 @@ import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
import {
createReviewDiffs,
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
@ -20,8 +26,41 @@ benchmark("benchmarks cold and hot session tab switching", async ({ browser, rep
report({ results, summary: summarize(results) })
})
async function trial(page: Page, mode: "cold" | "hot") {
await mockStressTimeline(page)
benchmark(
"benchmarks v2 session tab switching with and without the review pane",
async ({ browser, report }, testInfo) => {
benchmark.setTimeout(360_000)
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
const results = {
closed: { cold: [] as Result[], hot: [] as Result[] },
open: { cold: [] as Result[], hot: [] as Result[] },
}
for (const reviewPane of ["closed", "open"] as const) {
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < runs; run++) {
results[reviewPane][mode].push(
await withBenchmarkPage(
browser,
`session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
(page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
testInfo,
),
)
}
}
}
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
},
)
async function trial(
page: Page,
mode: "cold" | "hot",
options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
) {
const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
if (options?.newLayoutDesigns) await installTimelineSettings(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
@ -33,6 +72,10 @@ async function trial(page: Page, mode: "cold" | "hot") {
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (options?.reviewPane === "open") {
await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
@ -70,6 +113,15 @@ function summarize(results: Record<"cold" | "hot", Result[]>) {
)
}
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
return Object.fromEntries(
Object.entries(results).map(([reviewPane, values]) => [
reviewPane,
summarize(values as Record<"cold" | "hot", Result[]>),
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
@ -77,3 +129,16 @@ async function switchSession(page: Page, sessionID: string, title: string) {
await tab.click()
await expectSessionTitle(page, title)
}
async function openReviewPane(page: Page) {
await page.getByRole("button", { name: "Toggle review" }).click()
const panel = page.locator("#review-panel")
await expect(panel).toBeVisible()
// Text-based readiness works across review implementations; the legacy list mounts
// diff viewers lazily while V2 mounts the active preview eagerly.
await page.waitForFunction(() => {
const panel = document.querySelector<HTMLElement>("#review-panel")
const text = panel?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
}

View file

@ -4,7 +4,15 @@ export type SessionSwitchSample = {
source: string[]
hasVisibleRows: boolean
last: boolean
requiredPartVisible?: boolean
bottomAnchorRequired?: boolean
bottomErrorPx?: number
review?: {
fileHost: boolean
fileHostReplaced: boolean
header: string
replacedLevels: string[]
}
}
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
@ -23,6 +31,10 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
).length,
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length,
reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length,
reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))],
reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))],
}
}
@ -31,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}

View file

@ -8,19 +8,56 @@ type SessionSwitchProbe = {
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
},
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
@ -36,6 +73,13 @@ async function installSessionSwitchProbe(
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
@ -43,10 +87,22 @@ async function installSessionSwitchProbe(
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
samples.push({
observedAtMs,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
requestAnimationFrame(sample)
}, 0)
@ -57,6 +113,9 @@ async function installSessionSwitchProbe(
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
},
{ capture: true, once: true },
@ -83,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
)
)
})
@ -101,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
switch: () => Promise<void>
},
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
try {
await run()
await waitForStableSessionSwitch(page)
return await collectSessionSwitchResult(page)
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
})
}
}
export async function waitForStableTimeline(page: Page, lastID: string) {

View file

@ -93,36 +93,53 @@ const assistantMessage = {
parts: [editPart],
}
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
export async function setupTimelineBenchmark(
page: Page,
options: {
historyTurns: number
eventBatch: number
newLayoutDesigns?: boolean
vcsDiff?: unknown[]
turnDiffs?: unknown[]
},
) {
const events: EventPayload[] = []
let eventBatch = options.eventBatch
const currentUserMessage = options.turnDiffs
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
: userMessage
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
vcsDiff: options.vcsDiff,
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
userMessage,
currentUserMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
},
}),
)
})
await page.addInitScript(
(input) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
newLayoutDesigns: input.newLayoutDesigns,
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
},
}),
)
},
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
)
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()

View file

@ -1,3 +1,4 @@
import type { Page } from "@playwright/test"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import {
buildInitialStreamEvent,
@ -6,80 +7,300 @@ import {
textPartID,
} from "./session-timeline-benchmark.fixture"
import { startTimelineProfile } from "./session-timeline-profile"
import { createReviewDiffs } from "./timeline-test-helpers"
import {
collectTimelineStreamMetrics,
installTimelineStreamProbe,
startTimelineStreamProbe,
} from "./session-timeline-stream-probe"
type TimelineStreamOptions = {
newLayoutDesigns?: boolean
reviewDiffs?: boolean
reviewPane?: boolean
}
type ReviewPaneSample = {
observedAtMs: number
panelVisible: boolean
header: string
diffViewers: number
diffLines: number
codeBlocks: number
ready: boolean
}
type ReviewPaneProbe = {
samples: ReviewPaneSample[]
start: () => void
stop: () => void
}
const reviewReadyStreak = 3
benchmark.describe("performance: session timeline streaming", () => {
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
benchmark.setTimeout(480_000)
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
const minimal = process.env.TIMELINE_MINIMAL === "1"
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
const result = await runTimelineStreamBenchmark(page, {})
report(result.metrics, result.context)
})
benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
report(result.metrics, result.context)
})
benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
report(result.metrics, result.context)
})
benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => {
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true })
report(result.metrics, result.context)
})
})
benchmark.describe("performance: review pane", () => {
benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
benchmark.setTimeout(240_000)
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
const diffs = createReviewDiffs()
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch,
eventBatch: 1,
newLayoutDesigns: true,
vcsDiff: diffs,
})
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
const contentStart = performance.now()
fixture.transport.enqueue(buildInitialStreamEvent(1))
await expect(fixture.text).toBeVisible()
await expect(fixture.text).toContainText("Implementation plan")
const initialContentObservedMs = performance.now() - contentStart
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
const deltas = buildStreamDeltaEvents(deltaCount)
await startTimelineStreamProbe(page)
fixture.transport.enqueue(deltas)
await page.waitForFunction(
(finalIndex) =>
(
window as Window & {
__timelineStreamBenchmark?: { applied: { index: number }[] }
}
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
deltaCount,
{ timeout: 420_000 },
)
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text).toContainText("Streaming")
await fixture.waitForStableGeometry()
const metrics = await collectTimelineStreamMetrics(page, {
textPartID,
finalIndex: deltaCount,
navigations: benchmarkDiagnostics(page).navigations,
})
const delivered = deltas.length - fixture.transport.pendingCount()
await profile.stop()
const open = await measureReviewPaneLoad(page, diffs[0]!.file)
const switches = []
for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file))
report(
{
endToEndInitialContentObservedMs: initialContentObservedMs,
...metrics,
deliveredDeltas: delivered,
pendingDeltas: fixture.transport.pendingCount(),
open,
switches,
},
{
cpuThrottle,
profileCPU,
profileVisual,
minimal,
queuedDeltas: deltas.length,
historyTurns,
eventBatch,
reviewDiffs: diffs.length,
},
)
await profile.reset()
})
})
async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) {
const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000)
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
const minimal = process.env.TIMELINE_MINIMAL === "1"
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch,
newLayoutDesigns: options.newLayoutDesigns,
// Turn diffs exercise timeline data cost; the pane-open scenario serves the same
// diffs through the default git mode so it works across review implementations.
turnDiffs: options.reviewDiffs ? diffs : undefined,
vcsDiff: options.reviewPane ? diffs : undefined,
})
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
const contentStart = performance.now()
await expect(fixture.text).toBeVisible()
await expect(fixture.text).toContainText("Implementation plan")
const initialContentObservedMs = performance.now() - contentStart
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined
if (reviewPane) await fixture.waitForStableGeometry()
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
const deltas = buildStreamDeltaEvents(deltaCount)
await startTimelineStreamProbe(page)
fixture.transport.enqueue(deltas)
await page.waitForFunction(
(finalIndex) =>
(
window as Window & {
__timelineStreamBenchmark?: { applied: { index: number }[] }
}
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
deltaCount,
{ timeout: completionTimeoutMs },
)
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text).toContainText("Streaming")
await fixture.waitForStableGeometry()
const metrics = await collectTimelineStreamMetrics(page, {
textPartID,
finalIndex: deltaCount,
navigations: benchmarkDiagnostics(page).navigations,
})
const delivered = deltas.length - fixture.transport.pendingCount()
await profile.stop()
const result = {
metrics: {
endToEndInitialContentObservedMs: initialContentObservedMs,
...metrics,
deliveredDeltas: delivered,
pendingDeltas: fixture.transport.pendingCount(),
reviewPane: reviewPane ?? null,
},
context: {
cpuThrottle,
profileCPU,
profileVisual,
minimal,
queuedDeltas: deltas.length,
historyTurns,
eventBatch,
newLayoutDesigns: options.newLayoutDesigns === true,
reviewPane: options.reviewPane === true ? "open" : "closed",
reviewDiffs: diffs?.length ?? 0,
},
}
await profile.reset()
return result
}
async function measureReviewPaneLoad(page: Page, file: string) {
// Default git mode reads the mocked /vcs/diff data, so opening the pane is enough
// and the flow works across review pane implementations.
await installReviewPaneProbe(page, { file })
await startReviewPaneProbe(page)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toBeVisible()
return collectReviewPaneProbe(page)
}
async function measureReviewNextFile(page: Page, file: string) {
await installReviewPaneProbe(page, { file })
await startReviewPaneProbe(page)
await page.getByRole("button", { name: "Next file" }).click()
return collectReviewPaneProbe(page)
}
async function installReviewPaneProbe(page: Page, input: { file: string }) {
await page.evaluate((input) => {
const samples: ReviewPaneSample[] = []
const basename = input.file.split(/[\\/]/).at(-1) ?? input.file
let started: number | undefined
let running = true
const paneState = () => {
const panel = document.querySelector<HTMLElement>("#review-panel")
const review = panel?.querySelector<HTMLElement>('[data-component="session-review-v2"]')
const rect = (review ?? panel)?.getBoundingClientRect()
const text = panel?.textContent ?? ""
const previewHeader = panel?.querySelector<HTMLElement>(
'[data-slot="session-review-v2-file-header"]',
)?.textContent
const header = previewHeader ?? text
const viewers = panel ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')] : []
const codeBlocks = panel?.querySelectorAll("code").length ?? 0
const diffLines = viewers.reduce(
(sum, viewer) =>
sum +
(viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length),
0,
)
const panelVisible =
!!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0
return {
panelVisible,
header: header.slice(0, 500),
diffViewers: viewers.length,
diffLines,
codeBlocks,
ready:
panelVisible &&
header.includes(basename) &&
(viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0),
}
}
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
samples.push({ observedAtMs: performance.now() - started, ...paneState() })
if (performance.now() - started < 10_000) sample()
}, 0)
})
}
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = {
samples,
start: () => {
started = performance.now()
performance.mark("opencode.review-pane.click")
sample()
},
stop: () => {
running = false
},
}
}, input)
}
async function startReviewPaneProbe(page: Page) {
await page.evaluate(() => {
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start()
})
}
async function collectReviewPaneProbe(page: Page) {
await page.waitForFunction((streak) => {
const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + streak)
return stable.length === streak && stable.every((sample) => sample.ready)
})
}, reviewReadyStreak)
const samples = await page.evaluate(() => {
const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!
probe.stop()
return probe.samples
})
return { summary: summarizeReviewPaneSamples(samples), samples }
}
function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) {
const firstReady = samples.find((sample) => sample.ready)
const stableIndex = samples.findIndex((_, index) => {
const stable = samples.slice(index, index + reviewReadyStreak)
return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready)
})
return {
samples: samples.length,
firstReadyObservedMs: firstReady?.observedAtMs ?? null,
stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs,
notReadySamples: samples.filter((sample) => !sample.ready).length,
maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)),
maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)),
maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)),
}
}

View file

@ -21,7 +21,10 @@ export async function installTimelineSettings(page: Page) {
export function mockStressTimeline(
page: Page,
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
input?: {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
vcsDiff?: unknown[]
},
) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
@ -30,6 +33,7 @@ export function mockStressTimeline(
project: fixture.project,
pageMessages,
onMessages: input?.onMessages,
vcsDiff: input?.vcsDiff,
})
}
@ -45,7 +49,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
...sessionIDs.map((sessionId) => ({
type: "session",
@ -78,3 +82,53 @@ export function stressDraftHref(draftID: string) {
function stressServer() {
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
}
export function createReviewDiffs() {
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
const before = reviewSource(index, lines)
const after = before
.replace(`value_${index}_4`, `updated_${index}_4`)
.replace(
`value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
`updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
)
.replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
return {
file,
patch: reviewPatch(file, before, after),
additions: 3,
deletions: 3,
status: "modified" as const,
}
})
}
function reviewSource(seed: number, lines: number) {
return Array.from(
{ length: lines },
(_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
).join("\n")
}
function reviewPatch(file: string, before: string, after: string) {
const beforeLines = before.split("\n")
const afterLines = after.split("\n")
return [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
...beforeLines.flatMap((line, index) => {
const next = afterLines[index]!
if (line === next) return [` ${line}`]
return [`-${line}`, `+${next}`]
}),
].join("\n")
}
function reviewWords(seed: number, length: number) {
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
}

View file

@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [{ id: "session" }],
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
return gate.promise
},
onMessages: (request) => events.push(request.phase),
pageMessages: () => {
events.push("page")
return { items: [] }
},
})
const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
expect(events).toEqual(["start", "before"])
const released = performance.now()
gate.resolve()
await response
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})

View file

@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: false,
bottomErrorPx: 0,
},
])
expect(result.firstCorrectObservedMs).toBeNull()
})
test("can measure content correctness without requiring a bottom anchor", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: true,
bottomAnchorRequired: false,
},
])
expect(result.firstCorrectObservedMs).toBe(16)
})

View file

@ -0,0 +1,55 @@
import { expect, test } from "bun:test"
import type { Page } from "@playwright/test"
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
function testPage(waitFailure?: Error) {
const stops: unknown[] = []
const page = {
evaluate: async (_callback: unknown, input?: unknown) => {
if (input) return
stops.push(undefined)
},
waitForFunction: async () => {
if (waitFailure) throw waitFailure
},
} as unknown as Page
return { page, stops }
}
function input(run: () => Promise<void>) {
return {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
href: "/session/destination",
switch: run,
}
}
test("stops sampling when the session switch fails", async () => {
const failure = new Error("switch failed")
const context = testPage()
await expect(
measureSessionSwitch(
context.page,
input(async () => Promise.reject(failure)),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})
test("stops sampling when the stable wait fails", async () => {
const failure = new Error("stable wait failed")
const context = testPage(failure)
await expect(
measureSessionSwitch(
context.page,
input(async () => {}),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})

View file

@ -0,0 +1,392 @@
import { expect, test } from "bun:test"
import {
analyzeVisualStability,
analyzeVisualStabilityByMarker,
type VisualStabilityTrace,
} from "../../utils/visual-stability"
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
return { markers: [], samples }
}
test("accepts continuous visible motion", () => {
expect(
analyzeVisualStability(
trace([
frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
]),
{ flow: ["changing", "following"] },
),
).toEqual([])
})
test("reports repeated geometry reversals", () => {
const issues = analyzeVisualStability(
trace([
frame(0, region({ width: 80 })),
frame(16, region({ width: 60 })),
frame(32, region({ width: 78 })),
frame(48, region({ width: 62 })),
]),
)
expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
})
test("reports visible blanking, label reversal, and overlap", () => {
const issues = analyzeVisualStability(
trace([
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
]),
{ flow: ["changing", "following"] },
)
expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
})
test("reports duplicate regions and unexpected remounts", () => {
const issues = analyzeVisualStability(
trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
{ stable: ["changing"], unique: ["changing"] },
)
expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
})
test("reports bottom anchor loss but permits movement while scrolled away", () => {
const anchored = analyzeVisualStability(
trace([
{ at: 0, regions: { changing: region() }, viewport: viewport(0) },
{ at: 16, regions: { changing: region() }, viewport: viewport(24) },
]),
{ preserveBottomAnchor: true },
)
const away = analyzeVisualStability(
trace([
{ at: 0, regions: { changing: region() }, viewport: viewport(80) },
{ at: 16, regions: { changing: region() }, viewport: viewport(104) },
]),
{ preserveBottomAnchor: true },
)
expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
expect(away).toEqual([])
})
test("reports up down up movement while preserving a bottom anchor", () => {
const issues = analyzeVisualStability(
trace([
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
{ at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
{ at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
{ at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
]),
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
)
expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
})
test("accepts monotonic upward movement while preserving a bottom anchor", () => {
expect(
analyzeVisualStability(
trace([
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
{ at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
{ at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
]),
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
),
).toEqual([])
})
test("ignores overlap entirely outside the clipped timeline viewport", () => {
expect(
analyzeVisualStability(
trace([
{
at: 0,
regions: {
changing: region({ top: -200, bottom: -100 }),
following: region({ top: -150, bottom: -50 }),
},
viewport: viewport(0),
},
]),
{ flow: ["changing", "following"] },
),
).toEqual([])
})
test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
const issues = analyzeVisualStability(
trace([
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
{ at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
]),
{ fixed: ["anchor"] },
)
expect(issues).toEqual([])
const moved = analyzeVisualStability(
trace([
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
{ at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
]),
{ fixed: ["anchor"] },
)
expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
})
test("analyzes each marked event independently", () => {
const input: VisualStabilityTrace = {
markers: [
{ at: 10, label: "grow" },
{ at: 40, label: "shrink" },
],
samples: [
frame(0, region({ top: 100 })),
frame(16, region({ top: 90 })),
frame(32, region({ top: 80 })),
frame(48, region({ top: 90 })),
frame(64, region({ top: 100 })),
],
}
expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
expect(
analyzeVisualStabilityByMarker(input, {
maxPositionReversals: 0,
motion: ["changing"],
aggregateMotion: false,
}),
).toEqual([])
})
test("reports cross-event motion reversals by default", () => {
const input: VisualStabilityTrace = {
markers: [
{ at: 10, label: "up" },
{ at: 40, label: "down" },
],
samples: [
frame(0, region({ top: 100 })),
frame(16, region({ top: 90 })),
frame(32, region({ top: 80 })),
frame(48, region({ top: 90 })),
frame(64, region({ top: 100 })),
],
}
expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
})
test("reports regions rendered in the wrong flow order", () => {
const issues = analyzeVisualStability(
trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
{ flow: ["changing", "following"] },
)
expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
})
test("uses painted bounds instead of clipped layout overflow", () => {
expect(
analyzeVisualStability(
trace([
frame(
0,
region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
region({ top: 140, bottom: 180 }),
),
]),
{ flow: ["changing", "following"] },
),
).toEqual([])
})
test("does not report disappearance when a present row moves outside the viewport", () => {
expect(
analyzeVisualStability(
trace([
frame(0, region({ visible: true })),
frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
frame(32, region({ visible: true })),
]),
),
).toEqual([])
})
test("reports an in-viewport transparent frame between visible frames", () => {
const issues = analyzeVisualStability(
trace([
frame(0, region()),
frame(16, region({ visible: false, opacity: 0, inViewport: true })),
frame(32, region()),
]),
)
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
})
test("reports an in-viewport display-none frame between visible frames", () => {
const issues = analyzeVisualStability(
trace([
frame(0, region()),
frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
frame(32, region()),
]),
)
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
})
test("can limit motion analysis to unaffected regions", () => {
expect(
analyzeVisualStability(
trace([
frame(0, region({ height: 20 }), region()),
frame(16, region({ height: 40 }), region()),
frame(32, region({ height: 30 }), region()),
]),
{ motion: ["following"] },
),
).toEqual([])
})
test("reports a blank frame across replacement surfaces", () => {
const issues = analyzeVisualStability(
{
markers: [],
samples: [
{ at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
{
at: 16,
regions: {
thinking: region({ present: false, visible: false }),
error: region({ present: false, visible: false }),
},
},
{ at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
],
},
{ continuousAny: [["thinking", "error"]] },
)
expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
})
test("reports failure to acquire the bottom anchor", () => {
const issues = analyzeVisualStability(
trace([
{ at: 0, regions: { changing: region() }, viewport: viewport(600) },
{ at: 16, regions: { changing: region() }, viewport: viewport(120) },
]),
{ acquireBottomAnchor: true },
)
expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
})
test("reports a required region that never renders", () => {
const issues = analyzeVisualStability(
trace([
{
at: 0,
regions: { changing: region({ present: false, visible: false }) },
},
]),
{ required: ["changing"] },
)
expect(issues).toContain("changing never rendered")
})
test("preserves typed region names while mapping definitions", () => {
const regions = defineVisualRegions({
changing: { selector: "[data-changing]" },
following: { selector: "[data-following]", closest: "[data-row]" },
})
const selectors = mapVisualRegions(regions, (region) => region.selector)
expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
const name: keyof typeof selectors = "changing"
expect(name).toBe("changing")
})
test("evaluates the typed invariant algebra over explicit observations", () => {
const regions = defineVisualRegions({
changing: { selector: "[data-changing]" },
following: { selector: "[data-following]" },
})
const invariants = [
{ type: "required", regions: ["changing"] },
{ type: "flow", regions: ["changing", "following"] },
] satisfies VisualInvariant<keyof typeof regions>[]
// @ts-expect-error Plans reject names that are not in the region definition.
const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
const plan = visualPlan(regions, invariants, { perMarker: true })
expect(invalid.regions).toEqual(["missing"])
expect(plan.perMarker).toBe(true)
expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
"changing overlapped following by 1px at 0ms",
])
})
test("legacy plan adapter preserves analyzer messages and order", () => {
const input = trace([
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
])
const options = { flow: ["changing", "following"], stable: ["changing"] }
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
analyzeVisualStability(input, options),
)
})
function frame(
at: number,
changing: VisualStabilityTrace["samples"][number]["regions"][string],
following?: VisualStabilityTrace["samples"][number]["regions"][string],
) {
return { at, regions: { changing, ...(following ? { following } : {}) } }
}
function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
return {
present: true,
visible: true,
inViewport: true,
top: 0,
bottom: 20,
width: 100,
height: 20,
opacity: 1,
count: 1,
node: 1,
label: "",
text: "",
layoutTop: input.top ?? 0,
layoutBottom: input.bottom ?? 20,
...input,
}
}
function viewport(distanceFromBottom: number) {
return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
}

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"
@ -14,7 +15,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
@ -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)
@ -52,7 +53,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},
@ -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

@ -0,0 +1,187 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/FileBrowserSidebar"
const projectID = "proj_file_browser_sidebar"
const sessionID = "ses_file_browser_sidebar"
const title = "File browser sidebar"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The file-browser sidebar must stay mounted across preview/pinned file-tab
// switches. Remounting resets scroll and filter state.
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
await setup(page)
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()
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
await panel.getByRole("button", { name: "file-00.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const scrolled = await viewport.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await writeProbe(page)
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("tab", { name: "file-78.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
expect(await readProbe(page)).toBe(PROBE)
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) {
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(
page: Page,
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "file-browser-sidebar",
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) return []
return files.map((name) => ({
name,
path: name,
absolute: `${directory}/${name}`,
type: "file" as const,
ignored: false,
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
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 },
)
}

View file

@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_panel_corner"
const directory = "C:/OpenCode/NewSessionPanelCorner"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({
viewport: { width: 935, height: 522 },
deviceScaleFactor: 1,
})
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_panel_corner",
worktree: directory,
vcs: "git",
name: "new-session-panel-corner",
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: true } }))
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
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 expectAppVisible(page.locator('[data-component="prompt-input"]'))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
await expect(panel).toHaveCount(1)
const box = await panel.boundingBox()
if (!box) throw new Error("New-session panel bounds are unavailable")
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
const corners = await page.evaluate(
async ({ source, points }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
points: [
{ x: Math.floor(box.x), y: Math.floor(box.y) },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
],
},
)
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
})

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

@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
await expect(input).toHaveText("keep me")
})

View file

@ -54,19 +54,16 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('[data-component="prompt-variant-control"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()

View file

@ -0,0 +1,302 @@
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"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
const sessionA = session("ses_server_a", directoryA, "Server A session")
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await mockServers(page, permissionRequests)
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
permissionRequests.length = 0
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(input).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
})
await mockServers(page, permissionRequests, permissionResponses)
await configureServers(page, [
{ type: "session", server: serverA, sessionId: sessionA.id },
{ type: "session", server: serverB, sessionId: sessionB.id },
])
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("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()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a",
type: "permission.asked",
properties: {
id: "permission-background-a",
sessionID: sessionA.id,
permission: "bash",
patterns: ["git status"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
])
await transport.send({
directory: directoryA,
payload: {
id: "event-permission-background-a-child",
type: "permission.asked",
properties: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
permission: "bash",
patterns: ["git diff"],
metadata: {},
always: [],
},
},
})
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { response: "once" },
},
{
origin: serverA,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { response: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
sessionID: string
permissionID: string
body: unknown
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
},
{ serverB, tabs },
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
sessionID: response[1]!,
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
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 === "/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)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") {
permissionRequests.push(url.toString())
return json(route, [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].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, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: directory,
config: directory,
worktree: directory,
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, {})
})
}
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
function provider(id: string) {
const name = id === "server-b" ? "Server B" : "Server A"
return {
all: [
{
id,
name: `${name} Provider`,
models: {
[id]: {
id,
name: `${name} Model`,
family: id,
release_date: "2026-01-01",
limit: { context: 200_000 },
},
},
},
],
connected: [id],
default: { providerID: id, modelID: id },
}
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}

View file

@ -0,0 +1,131 @@
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"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
await mockServers(page)
await page.addInitScript(
({ serverA, serverB, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: serverA, sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
// Session B is busy on server B while server A stays the active server, so the
// busy indicator must come from the tab server's status, not the active server's.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
})
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
async function mockServers(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
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" || 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, [])
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"].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" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
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, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route, current: boolean) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
}

View file

@ -0,0 +1,202 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewImageFlashRegression"
const sessionID = "ses_review_image_flash_regression"
const title = "Review image flash regression"
const imageFile = "assets/preview.png"
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
await openReview(page)
await installReviewFlashProbe(page)
await page.getByRole("button", { name: /preview\.png/ }).click()
await waitForReviewFlashProbe(page, 400)
const trace = await collectReviewFlashProbe(page)
const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter)
expect(trace.samples.length).toBeGreaterThan(0)
expect(
bad,
JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2),
).toEqual([])
})
async function openReview(page: Page) {
await page.setViewportSize({ width: 960, height: 900 })
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_image_flash_regression",
worktree: directory,
vcs: "git",
name: "review-image-flash-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-image-flash-regression",
projectID: "proj_review_image_flash_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/example.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
{
file: imageFile,
patch: "",
additions: 1,
deletions: 0,
status: "added",
},
],
fileContent: async (path) => {
if (path !== imageFile) return undefined
await new Promise((resolve) => setTimeout(resolve, 250))
return {
type: "binary",
content: "iVBORw0KGgo=",
encoding: "base64",
mimeType: "image/png",
}
},
fileList: (path) => {
if (!path) {
return [
{ name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false },
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
}
if (path === "assets") {
return [
{
name: "preview.png",
path: imageFile,
absolute: `${directory}/${imageFile}`,
type: "file",
ignored: false,
},
]
}
if (path === "src") {
return [
{
name: "example.ts",
path: "src/example.ts",
absolute: `${directory}/src/example.ts`,
type: "file",
ignored: false,
},
]
}
return []
},
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_image_flash_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_image_flash_regression",
sessionID,
messageID: "msg_review_image_flash_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
await expectAppVisible(page.getByRole("button", { name: /preview\.png/ }))
}
async function installReviewFlashProbe(page: Page) {
await page.evaluate(() => {
const samples: Array<{
observedAtMs: number
blank: boolean
blackCenter: boolean
text: string
background: string
}> = []
const startedAt = performance.now()
const sample = () => {
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
const rect = panel?.getBoundingClientRect()
const center = rect
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
: undefined
const background = center instanceof Element ? getComputedStyle(center).backgroundColor : ""
samples.push({
observedAtMs: performance.now() - startedAt,
blank: !panel || panel.textContent?.trim().length === 0,
blackCenter: background === "rgb(0, 0, 0)",
text: panel?.textContent?.trim().slice(0, 80) ?? "",
background,
})
if (performance.now() - startedAt < 500) requestAnimationFrame(sample)
}
document.addEventListener(
"click",
(event) => {
const target = event.target instanceof Element ? event.target : undefined
if (!target?.closest('[data-slot="file-tree-v2-row"]')) return
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = {
samples,
startedAt,
}
})
}
async function waitForReviewFlashProbe(page: Page, durationMs: number) {
await page.waitForFunction((durationMs) => {
const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } })
.__reviewImageFlash
return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs
}, durationMs)
}
async function collectReviewFlashProbe(page: Page) {
return page.evaluate(() => {
return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash!
}) as Promise<{
startedAt: number
samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }>
}>
}

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

@ -0,0 +1,163 @@
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/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
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("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
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: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
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: false, 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")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}

View file

@ -0,0 +1,153 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
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("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v1",
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
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: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}

View file

@ -0,0 +1,147 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTabSwitch"
const projectID = "proj_review_tab_switch"
const sessionA = "ses_review_tab_a"
const sessionB = "ses_review_tab_b"
const titleA = "Alpha session"
const titleB = "Beta session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const diffs = Array.from({ length: 2_740 }, (_, index) =>
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
)
// Marks the review pane DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function switchTab(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
}
async function writeProbe(page: Page) {
await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-tab-switch",
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: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
vcsDiff: diffs,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function fileDiff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}

View file

@ -0,0 +1,306 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
const sessionID = "ses_review_terminal_stacked"
const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
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,
worktree: directory,
vcs: "git",
name: "review-terminal-stacked",
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: "review-terminal-stacked",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: () => sessionStatus,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
branch: "review-pane-performance",
default_branch: "dev",
}),
}),
)
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
})
})
await page.route("**/pty*", (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*", (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(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
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) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
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")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible()
}
async function expectMountedTree(page: Page, total: number) {
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
await expect(tree).toHaveAttribute("data-total-rows", String(total))
await expect
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
.toBeGreaterThan(0)
const state = await tree.evaluate((element) => ({
root: element.getBoundingClientRect().height,
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
}))
expect(state.viewport).toBeGreaterThan(0)
expect(state.root).toBeGreaterThan(0)
expect(state.rows).toBeGreaterThan(0)
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
return {
review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}

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