Compare commits

...

488 Commits

Author SHA1 Message Date
Aiden Cline ac384f3d27 feat(opencode): add experimental code-mode MCP adapter 2026-07-03 00:17:49 -05:00
Aiden Cline 92a50f2397 refactor(codemode): clean up package comments 2026-07-03 00:08:01 -05:00
Aiden Cline ac7ee99a24 feat(codemode): add confined execution package 2026-07-03 00:01:06 -05: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
deploy / deploy (push) Has been cancelled
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)
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
deploy / deploy (push) Has been cancelled
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)
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
deploy / deploy (push) Has been cancelled
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)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
deploy / deploy (push) Has been cancelled
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)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (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
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
deploy / deploy (push) Has been cancelled
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)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (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
containers / build (push) Has been cancelled
generate / generate (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
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
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)
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
deploy / deploy (push) Has been cancelled
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
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
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
opencode-agent[bot] 41f2705e72 chore: generate 2026-06-26 03:35:39 +00:00
Kit Langton ef5c9f4931 feat(sdk): expose active sessions (#33991) 2026-06-26 03:33:35 +00:00
Kit Langton 40ecfceec2 fix(sdk): wake embedded session execution (#33992) 2026-06-25 23:04:14 -04:00
Kit Langton a1f093a748 refactor(opencode): stop legacy v2 event emission (#33993) 2026-06-26 04:42:38 +02:00
opencode-agent[bot] 9859b19b93 fix(ui): normalize tooltip trigger layout (#33979)
Co-authored-by: Test <test@opencode.test>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-26 02:42:16 +00:00
opencode-agent[bot] 45badf7163 fix(app): suspend hidden terminal renderer (#33990)
Co-authored-by: Test <test@opencode.test>
2026-06-26 02:28:36 +00:00
opencode-agent[bot] 28a00ad6af fix(app): enable auto-accept in session settings (#33974) 2026-06-26 11:55:21 +10:00
Aiden Cline 0f272d931d tweak: hide MCP access token prefix (#33711) 2026-06-25 20:32:36 -05:00
Aiden Cline ded29f03f0 fix(core): refine small model defaults (#33926) 2026-06-25 20:31:53 -05:00
opencode-agent[bot] f428755851 feat(core): split MCP timeout configuration (#33977)
Co-authored-by: Test <test@opencode.test>
2026-06-25 20:31:00 -05:00
opencode-agent[bot] 17166b271f fix(app): simplify question prompt (#33968)
Co-authored-by: Test <test@opencode.test>
2026-06-26 09:22:36 +10:00
Aiden Cline 5f61d21487 feat(llm): pass strict through tool definitions for Codex parity (#33392) 2026-06-25 16:28:39 -05:00
opencode-agent[bot] 0568855cc6 chore: generate 2026-06-25 21:28:07 +00:00
Aiden Cline 3fbc005f26 fix(mcp): scope auth status to server URL (#33924) 2026-06-25 16:26:00 -05:00
opencode-agent[bot] 21c0e03a70 chore: generate 2026-06-25 20:26:11 +00:00
Brendan Allan a2b847e29e refactor(app): split session composer (#33954) 2026-06-25 20:23:58 +00:00
Brendan Allan fae100fe72 fix(app): enable composer shortcuts on drafts (#33956) 2026-06-26 04:05:36 +08:00
Brendan Allan b60c0a5e8c fix(app): use tab-scoped servers in sessions (#33946) 2026-06-25 19:09:22 +00:00
opencode-agent[bot] 0870a838a8 chore: generate 2026-06-25 18:36:16 +00:00
James Long 0e731c2a1a refactor(core): support tiered layer nodes (#33937) 2026-06-25 14:34:17 -04:00
opencode-agent[bot] 21b78c49fc chore: generate 2026-06-25 18:25:16 +00:00
Kit Langton f44423609b feat(sdk): restore session runtime operations (#33777) 2026-06-25 14:23:01 -04:00
Brendan Allan 44806777ca feat(app): group prompt projects by server (#33941) 2026-06-25 17:47:45 +00:00
Kit Langton a267222080 refactor(schema): isolate v1 contracts (#33769) 2026-06-25 17:26:47 +00:00
opencode-agent[bot] ecabab49a7 chore: generate 2026-06-25 17:13:12 +00:00
Kit Langton 9e9d405d7e refactor(schema): tighten public contracts (#33771) 2026-06-25 17:10:23 +00:00
opencode-agent[bot] 5682371d0a chore: generate 2026-06-25 17:03:14 +00:00
Brendan Allan 10e4b87d25 fix(app): persist home project selection (#33935) 2026-06-25 17:00:19 +00:00
Simon Klee e7c59b17a8 fix(tui): load root sessions in session switcher (#33931)
Request root sessions before applying the session list limit so child
sessions cannot crowd roots out of the switcher.

Keep the synchronized cache available while requests are pending or fail,
reconcile results with live updates, and retain current and pinned sessions.
Preserve selection by session ID when asynchronous results reorder the list.

Closes #16270
Closes #32725
2026-06-25 18:36:08 +02:00
Brendan Allan 69f75dff1a fix(app): adjust home search and header layout (#33923) 2026-06-26 00:19:20 +08:00
Frank fec087bf37 zen: new inference
deploy / deploy (push) Has been cancelled
2026-06-25 12:15:34 -04:00
Frank 4f4aa68db9 zen: migrate to new inference 2026-06-25 11:55:37 -04:00
opencode-agent[bot] 77f2d2227a chore: generate 2026-06-25 15:44:27 +00:00
Kit Langton 7854f5b9f7 refactor(schema): normalize module patterns (#33770) 2026-06-25 15:42:22 +00:00
Brendan Allan 5565a8ee10 fix(app): restore new layout shortcuts (#33929) 2026-06-25 23:22:12 +08:00
Kit Langton 9ad7067971 docs(schema): document package conventions (#33768) 2026-06-25 11:12:09 -04:00
opencode-agent[bot] ddbc8d3b75 chore: generate 2026-06-25 14:56:25 +00:00
Brendan Allan 4f88cab20d fix(app): suspend while recent models load (#33921) 2026-06-25 14:53:45 +00:00
Adam 208cd56d03 feat(stats): add i18n support (#33693) 2026-06-25 09:50:29 -05:00
Brendan Allan c097d51135 feat(app): improve titlebar tab layout (#33914) 2026-06-25 14:39:59 +00:00
opencode-agent[bot] 20fd323591 chore: generate 2026-06-25 13:57:16 +00:00
Dax 7f40aeab21 feat(server): add permission request endpoints (#33774) 2026-06-25 13:55:17 +00:00
opencode-agent[bot] bcff162c60 chore: update nix node_modules hashes 2026-06-25 12:26:33 +00:00
Brendan Allan 5ea405bf81 fix(app): hide mobile titlebar setting in production (#33894) 2026-06-25 20:18:14 +08:00
opencode 7c0e1b431c sync release versions for v1.17.11 2026-06-25 12:09:26 +00:00
Victor Navarro f4103f5c3a feat(ui): publish ui package (#33681) 2026-06-25 14:04:49 +02:00
opencode-agent[bot] e6c45134ab fix(client): exclude generated files from formatting (#33832)
Co-authored-by: Test <test@opencode.test>
2026-06-25 07:53:00 -04:00
opencode-agent[bot] f55d8fa3ff chore: update nix node_modules hashes 2026-06-25 08:56:29 +00:00
Brendan Allan 78234fa305 fix(app): use project server for prompt drafts (#33850) 2026-06-25 16:55:20 +08:00
Simon Klee 98dcea02a8 Revert "deps: update OpenTUI to 0.4.2 (#33610)" (#33842) 2026-06-25 10:38:09 +02:00
opencode-agent[bot] 0149d460e3 feat(app): add Chrome tab cycle shortcuts (#33838) 2026-06-25 16:25:42 +08:00
opencode-agent[bot] 5471d2a769 fix(app): reveal tab avatar on hover (#33824)
Co-authored-by: Test <test@opencode.test>
2026-06-25 15:54:44 +08:00
Brendan Allan b44af11e03 fix(app): restore tab layout consistency (#33804)
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-25 15:00:12 +08:00
Brendan Allan 717b740d24 fix(app): improve home action availability (#33805) 2026-06-25 14:55:23 +08:00
Aarav Sareen b2c688f2a8 feat(app): restyle v2 jump-to-latest button (#33809) 2026-06-25 16:51:37 +10:00
opencode-agent[bot] 1b107301fb chore: generate 2026-06-25 06:07:21 +00:00
Aarav Sareen aee262f019 feat(app): no sesssions empty state (#33315) 2026-06-25 16:05:39 +10:00
Brendan Allan 6ec6db4ad6 fix(storybook): support prompt state capture (#33798) 2026-06-25 13:50:09 +08:00
Aarav Sareen 40ddede2b1 fix(app): update all v1 new-session icons (#32017)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-25 15:40:30 +10:00
Brendan Allan 71402bcbc3 fix(app): remove produce default values in server session state (#33793) 2026-06-25 13:35:08 +08:00
Brendan Allan d13779b1d5 fix(app): preserve todo dock across sessions (#33778) 2026-06-25 13:34:37 +08:00
Brendan Allan bdee94ca40 fix(app): close missing session tabs (#33785) 2026-06-25 13:28:32 +08:00
opencode-agent[bot] 9c0d813b8e chore: generate 2026-06-25 05:28:04 +00:00
Aarav Sareen 1bcb9d7cb9 feat(app): ui improvements (#32438)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-25 15:26:37 +10:00
opencode-agent[bot] 4c0af7cf2d chore: generate 2026-06-25 05:08:46 +00:00
Aarav Sareen d5aa5ff8fb feat(app): draggable tabs (#31364)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-25 15:06:49 +10:00
opencode-agent[bot] 5d4f160d64 chore: generate 2026-06-25 05:00:23 +00:00
Brendan Allan fed8f01016 refactor(app): lift parent session navigation (#33782) 2026-06-25 12:58:46 +08:00
opencode-agent[bot] 259e153d10 chore: generate 2026-06-25 04:43:34 +00:00
Luke Parker cfd75d62fe fix(app): separate provider lifetimes and reactive ownership (#33739) 2026-06-25 04:42:06 +00:00
opencode-agent[bot] dc569b5a5f chore: update nix node_modules hashes 2026-06-25 03:26:09 +00:00
opencode-agent[bot] 546527b7d6 chore: generate 2026-06-25 03:10:23 +00:00
Kit Langton cdd67cf30f feat(sdk): add HttpApi clients and embedded host (#33445) 2026-06-24 23:08:54 -04:00
opencode-agent[bot] c45d1db9a0 chore: update nix node_modules hashes 2026-06-25 02:28:42 +00:00
Brendan Allan ba2ba770d5 fix(app): clear late session notifications (#33753)
Co-authored-by: Test <test@opencode.test>
2026-06-25 02:21:42 +00:00
opencode-agent[bot] 5ca19947d9 chore: generate 2026-06-25 02:18:06 +00:00
Brendan Allan 3b4aaafd41 refactor(app): centralize session state (#33641) 2026-06-25 02:16:39 +00:00
Kit Langton 56a37c3640 refactor(protocol): extract server contracts (#33708) 2026-06-24 22:15:31 -04:00
opencode-agent[bot] f9dac262ff fix(app): remove session loading stripe (#33649)
Co-authored-by: Test <test@opencode.test>
2026-06-25 10:09:06 +08:00
opencode-agent[bot] 3730125f8f chore: generate 2026-06-24 23:43:02 +00:00
Dax 9bb5370205 feat(core): add session snapshot and revert system (#33226) 2026-06-24 23:41:16 +00:00
Dax Raad 25c6abc31a fix(core): lower OpenAI text verbosity 2026-06-24 19:32:59 -04:00
Dax Raad f4afb2c0a5 fix(core): preserve unconfigured console models 2026-06-24 18:58:17 -04:00
Dax Raad bba74985ab fix(core): handle unavailable fff index 2026-06-24 18:49:22 -04:00
Dax Raad e9ee2129ea fix(core): disable fff broad scanning 2026-06-24 18:42:15 -04:00
Dax Raad 68260ea6c9 fix(core): scope fff broad scanning 2026-06-24 18:42:15 -04:00
Aiden Cline 49ea8a9455 fix(opencode): always print MCP OAuth URL (#33716) 2026-06-24 17:24:16 -05:00
Isaac Huang 142c5c11d9 docs: add GMI Cloud provider entry to providers directory (#32914) 2026-06-24 17:15:36 -05:00
opencode-agent[bot] 4b83f5d8f0 chore: update nix node_modules hashes 2026-06-24 20:56:38 +00:00
opencode-agent[bot] 121decfd15 chore: generate 2026-06-24 20:45:23 +00:00
Kit Langton 24b0132bc5 refactor(schema): extract public event definitions (#33579) 2026-06-24 16:43:17 -04:00
opencode 858f35f1b3 sync release versions for v1.17.10 2026-06-24 20:37:40 +00:00
Simon Klee 20750a3295 tui: remove logo animation (#33633)
Render branding as a static logo to avoid continuous redraws and mouse-driven visual effects.
2026-06-24 19:28:46 +00:00
Aiden Cline 1ddb75dd3e fix: restore dev CI (#33701) 2026-06-24 14:04:50 -05:00
Frank ea5dd9d940 zen: track model tier in stats
deploy / deploy (push) Has been cancelled
2026-06-24 14:34:58 -04:00
opencode-agent[bot] 31b58b4704 chore: generate 2026-06-24 17:21:26 +00:00
Dax Raad 42bb793574 feat(core): generate model variants 2026-06-24 13:17:14 -04:00
Dax Raad 17f312d537 refactor(core): simplify model requests 2026-06-24 13:17:14 -04:00
Aiden Cline 116ac93ddb fix(mcp): hide denied resource template tool (#33686) 2026-06-24 12:01:44 -05:00
opencode-agent[bot] 2684293291 chore: generate 2026-06-24 16:56:33 +00:00
4rcadia e8e83afbce feat(mcp): append server instructions to context (#32490)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-24 11:54:19 -05:00
Adam 2e909334c1 refactor(stats): simplify locale prefix stripping 2026-06-24 10:33:59 -05:00
Adam 69f3b633b8 fix(stats): strip locale prefixes from data routes 2026-06-24 10:33:58 -05:00
Frank db7c28b3ee zen: migrate to new inference 2026-06-24 11:15:42 -04:00
opencode-agent[bot] e45f55d922 chore: update nix node_modules hashes 2026-06-24 15:09:00 +00:00
Victor Navarro 04c1730c90 refactor(ui): isolate session components (#33670) 2026-06-24 14:56:09 +00:00
opencode-agent[bot] 1ef0fd5d01 chore: generate 2026-06-24 13:59:28 +00:00
Shoubhit Dash 36501a81c3 docs(llm): propose AI library design (#33666) 2026-06-24 19:27:21 +05:30
Simon Klee 6281e35a18 tui: fix multi-day duration formatting (#33651)
Report elapsed days and remaining hours instead of showing zero days and
the total duration as hours.

Close #32957
Close #32956
2026-06-24 12:05:06 +00:00
vimtor bd149f0daa fix(enterprise): use delete for remove share 2026-06-24 12:43:55 +02:00
Victor Navarro 6eec98371a feat(tui): add main branch source to diff mode (#30942) 2026-06-24 10:35:37 +00:00
Brendan Allan 0016dfd9b1 fix(app): always apply safe area insets (#33619) 2026-06-24 18:21:52 +08:00
opencode-agent[bot] fe655b7630 chore: generate 2026-06-24 10:09:04 +00:00
Victor Navarro 05f335ce62 feat(tui): add diff viewer keybind (#33365) 2026-06-24 12:07:23 +02:00
Victor Navarro ad3651d8f1 feat(console): add support actions (#33492) 2026-06-24 12:06:51 +02:00
vimtor 633fc6fc03 ci: notify the current Discord alert role 2026-06-24 11:55:43 +02:00
Brendan Allan cacb7d045f refactor(app): rename cached context accessors (#33627) 2026-06-24 08:55:57 +00:00
Brendan Allan 2312a15e57 fix(app): share synced session data (#33624) 2026-06-24 08:40:35 +00:00
opencode-agent[bot] d465cd476a chore: generate 2026-06-24 07:50:33 +00:00
Luke Parker a4551a94b4 fix(app): make session navigation stable and fast (#33569)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-24 07:48:54 +00:00
opencode-agent[bot] abcfeb380b chore: update nix node_modules hashes 2026-06-24 07:43:59 +00:00
Simon Klee 8cc79f7432 deps: update OpenTUI to 0.4.2 (#33610) 2026-06-24 09:22:24 +02:00
opencode-agent[bot] 67e8b8582e chore: generate
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-24 05:34:41 +00:00
Aarav Sareen 8aff7b8c7b feat(app): update all components to use v2 tokens (#33598) 2026-06-24 13:33:14 +08:00
Aiden Cline 6c12c32fb1 fix(mcp): prevent resource key collisions (#33596) 2026-06-24 00:13:58 -05:00
opencode-agent[bot] fbfd725d73 chore: generate 2026-06-24 05:10:01 +00:00
Chris Yuan 246d40db73 fix(skill): emit base directory as filesystem path, not file:// URL (#33580)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-24 00:08:22 -05:00
Aiden Cline 947e0017f5 fix(mcp): restore legacy tool names (#33593) 2026-06-23 23:42:32 -05:00
Brendan Allan 800d41ddab refactor(app): simplify layout hierarchy (#33588) 2026-06-24 04:37:08 +00:00
opencode-agent[bot] 5647ed8ba3 fix(opencode): restore v1 account config (#33590)
Co-authored-by: Test <test@opencode.test>
2026-06-24 04:22:02 +00:00
Yufeng He af31e97493 fix(mcp): bind oauth callback to IPv4 loopback (#30022) 2026-06-23 23:20:29 -05:00
Brendan Allan 5303ab09f8 fix(app): route new sessions through tabs (#33582) 2026-06-24 03:48:33 +00:00
Brendan Allan 51eb87b256 fix(app): clear viewed session notifications (#33574) 2026-06-24 11:37:44 +08:00
opencode-agent[bot] 556337d6dd chore: generate 2026-06-24 03:37:05 +00:00
Aiden Cline c416ede20d fix(tui): sort model picker by release date (#33558) 2026-06-23 22:35:47 -05:00
Dax Raad 784eea3911 feat(cli): add API request command 2026-06-23 23:35:20 -04:00
Brendan Allan a4b9351c0f fix(app): create scoped drafts from home (#33578) 2026-06-24 11:24:38 +08:00
Kit Langton 57895586c2 refactor(core): remove schema forwarding facades (#33577) 2026-06-24 02:52:58 +00:00
Brendan Allan 6298db7a77 fix(app): throttle directory tree loading (#33576) 2026-06-24 02:51:26 +00:00
opencode-agent[bot] 5202bf1bf2 chore: update nix node_modules hashes 2026-06-24 02:44:09 +00:00
Kit Langton 516cfe4e09 refactor(schema): extract shared public schemas (#33571) 2026-06-23 22:31:06 -04:00
Brendan Allan 60aba622ab fix(app): use fixed titlebar tab widths (#33572) 2026-06-24 02:05:44 +00:00
Dax Raad e8610d821c refactor(core): drop filesystem entry mime 2026-06-23 21:49:01 -04:00
Dax Raad 53076999c5 fix(tui): normalize autocomplete attachments 2026-06-23 21:41:14 -04:00
Brendan Allan f8c9bfd454 fix(app): mount shortcuts per titlebar tab (#33567) 2026-06-24 01:39:30 +00:00
Brendan Allan fed4f4d86b feat(app): keep prompt state in tabs (#33566) 2026-06-24 01:32:59 +00:00
opencode-agent[bot] 1490752059 chore: generate 2026-06-24 00:54:55 +00:00
Dax 4898263dec feat(core): map providers to integrations (#33562) 2026-06-24 00:53:31 +00:00
Dax c556bddda3 feat(core): add opencode integration (#33560) 2026-06-24 00:26:54 +00:00
opencode-agent[bot] cfddb2407c chore: generate 2026-06-23 23:46:36 +00:00
Dax cf80b5c470 feat(core): add opencode integration (#33555) 2026-06-23 23:45:02 +00:00
Aiden Cline c17b9557f1 fix(core): preserve structured error messages (#33530) 2026-06-23 18:43:59 -05:00
opencode-agent[bot] 10f6bb7878 chore: generate 2026-06-23 23:24:49 +00:00
Aiden Cline b8cfd69acf feat(tui): redesign crash screen (#33549) 2026-06-23 18:23:15 -05:00
opencode-agent[bot] d2305d4a76 chore: generate 2026-06-23 20:31:13 +00:00
Shoubhit Dash c6cc13e183 feat(mcp): add resource template listing (#33546) 2026-06-24 01:59:12 +05:30
Kit Langton dcf7b4e792 fix(opencode): handle snapshot paths from subdirectories (#33506) 2026-06-23 20:51:56 +02:00
Aiden Cline 5152150bfe fix(opencode): make ACP resource text sourcing cross-platform (#33534) 2026-06-23 12:55:34 -05:00
Brendan Allan 2ba18b84a5 fix(app): use correct server sdk for titlebar session lookup (#33536) 2026-06-24 01:54:43 +08:00
Brendan Allan e04c5e72f7 fix(app): prompt persistence and draft sessions (#33528) 2026-06-24 01:50:19 +08:00
Aiden Cline a131811cdc feat(mcp): use mcp__server__tool naming convention (#33533) 2026-06-23 12:28:26 -05:00
opencode-agent[bot] 976e5d421f ci: route support questions (#33527)
Co-authored-by: Test <test@opencode.test>
2026-06-23 12:20:30 -05:00
Shoubhit Dash 8e2d422ffe fix(acp): preserve resource text source (#33524) 2026-06-23 22:15:00 +05:30
Aiden Cline a3825286cf test(core): avoid models cache recovery race (#33525) 2026-06-23 11:40:08 -05:00
ReStranger e0c0411003 fix: Skip bun version check for nix version (#33166)
Signed-off-by: ReStranger <restranger@disroot.org>
Co-authored-by: dbeley <6568955+dbeley@users.noreply.github.com>
2026-06-23 11:10:55 -05:00
opencode-agent[bot] e439456b13 chore: generate 2026-06-23 15:47:46 +00:00
Aiden Cline e3edbba3ca test(opencode): stabilize prompt concurrency tests (#33522) 2026-06-23 10:45:30 -05:00
Simon Klee 3cdd431794 fix(tui): make move session command bindable (#33508)
Add the session_move TUI keybind and map it to the existing session.move command. Keep it unbound by default and document the new configuration option.

Closes #33239
2026-06-23 15:55:14 +02:00
Adam ef4500d6af fix(data): ignore table changes
deploy / deploy (push) Has been cancelled
2026-06-23 07:28:11 -05:00
Adam f416f52ae3 Reapply "feat(stats): add user ids to inference metrics"
This reverts commit e0ff4fd1ac.
2026-06-23 07:27:01 -05:00
Adam e0ff4fd1ac Revert "feat(stats): add user ids to inference metrics"
This reverts commit 428ae6d21a.
2026-06-23 07:11:51 -05:00
Adam 428ae6d21a feat(stats): add user ids to inference metrics 2026-06-23 07:05:26 -05:00
Brendan Allan d568fed095 fix(app): tighten mobile utility UI (#32799) 2026-06-23 10:41:05 +00:00
opencode-agent[bot] 58a8d9fcb0 chore: generate 2026-06-23 10:28:43 +00:00
Aarav Sareen d24848359d feat(app): new session progress indicator (#32662)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-23 20:27:07 +10:00
Brendan Allan a21e74773f fix(app): improve iOS PWA shell (#32798) 2026-06-23 09:59:30 +00:00
opencode-agent[bot] fc95a84b42 chore: generate 2026-06-23 09:30:58 +00:00
Brendan Allan b0063709e6 feat(app): add mobile bottom navigation (#32797) 2026-06-23 09:29:22 +00:00
opencode-agent[bot] 77522697b0 chore: generate 2026-06-23 09:00:19 +00:00
Brendan Allan bbc88eb8e3 fix(app): refine mobile session layout (#32796) 2026-06-23 16:58:05 +08:00
opencode-agent[bot] a379c7956b chore: generate 2026-06-23 08:47:47 +00:00
Aarav Sareen 52eae83ae4 fix(app): add server button dropdown (#33358) 2026-06-23 18:46:00 +10:00
Aarav Sareen 3cc626b5da feat(app): collapsible servers (#33384) 2026-06-23 18:41:54 +10:00
Luke Parker f0849a697c fix(app): reject stale timeline range indexes (#33488) 2026-06-23 18:27:25 +10:00
Shoubhit Dash 3f3f120825 feat(mcp): add resource read tools (#33483) 2026-06-23 13:49:42 +05:30
Frank ea17d9bed8 zen: glm 5.2 2026-06-23 04:03:07 -04:00
Frank 726b2d7e6b zen: remove unused fields 2026-06-23 04:03:07 -04:00
opencode-agent[bot] bffc5938fa chore: generate 2026-06-23 08:02:37 +00:00
Luke Parker d111812389 fix(app): restore review line comments (#33481) 2026-06-23 08:01:03 +00:00
Brendan Allan 597f47b486 fix(app): improve mobile home layout (#32789) 2026-06-23 16:00:21 +08:00
opencode-agent[bot] 09757c605a chore: generate
deploy / deploy (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
nix-hashes / update-hashes (push) Has been cancelled
publish / version (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
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-23 06:20:50 +00:00
Brendan Allan 0c4f508c50 feat(app): add server-keyed session routes (#32570) 2026-06-23 06:19:18 +00:00
opencode-agent[bot] 40db33c415 chore: generate 2026-06-23 04:57:55 +00:00
Dax Raad 9b01f15f1d test(opencode): retry Windows CLI cleanup 2026-06-23 00:56:14 -04:00
Dax Raad 81851ca6b9 test(opencode): stabilize Windows async readiness 2026-06-23 00:41:02 -04:00
Dax Raad 3c5632e110 test(opencode): stabilize Windows CLI subprocesses 2026-06-23 00:26:48 -04:00
Dax Raad 6ea3e6698a fix(opencode): scope reference readiness wait 2026-06-23 00:10:36 -04:00
Dax Raad ed75ce9ecc fix(core): prioritize reference plugin registration 2026-06-22 23:39:26 -04:00
Dax Raad af14fefc96 test(opencode): relax compaction readiness timeout 2026-06-22 23:01:05 -04:00
Dax Raad 4a710e4679 fix(core): await plugin readiness 2026-06-22 22:49:57 -04:00
Dax Raad d2c866bf70 test(core): speed up test setup 2026-06-22 22:49:57 -04:00
opencode-agent[bot] 237595e242 chore: generate 2026-06-23 02:19:31 +00:00
Aiden Cline d29f5eba92 refactor(core): remove shell description input (#32823) 2026-06-22 21:18:06 -05:00
Aiden Cline fbf889db83 fix(tui): preserve worker rejection handling (#33448)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-06-22 19:57:21 -05:00
Dax ef2357915e fix(tui): scope file autocomplete to session (#33458) 2026-06-22 20:31:36 -04:00
Dax Raad 975b1132f1 refactor(plugin): use direct runtime registry 2026-06-22 20:10:29 -04:00
Dax Raad cd97de7391 refactor(plugin): consolidate internal registration 2026-06-22 19:49:00 -04:00
Dax Raad 23fd5907be fix(opencode): normalize CLI test line endings 2026-06-22 19:15:50 -04:00
opencode-agent[bot] 49d3f86802 chore: generate 2026-06-22 23:08:26 +00:00
Dax 909a1a6d78 feat(plugin): add namespaced hook API (#33416) 2026-06-22 19:06:57 -04:00
Kit Langton dc468bdcfd fix(core): reset steps for promoted prompts (#33452) 2026-06-22 19:01:14 -04:00
Kit Langton f48f24ec4e refactor(core): simplify session input promotion (#33443) 2026-06-22 17:51:49 -04:00
Aiden Cline 34b3d59a23 ignore: update agents.md (#33446) 2026-06-22 15:52:43 -05:00
Frank a0a500316e zen: new inference
deploy / deploy (push) Has been cancelled
2026-06-22 15:40:14 -04:00
Kit Langton 1787fa4261 refactor(core): drop legacy compaction event (#33404) 2026-06-22 21:39:41 +02:00
Kit Langton 130957288e fix(llm): preserve structured tool errors (#33405) 2026-06-22 13:43:07 -04:00
1523 changed files with 96334 additions and 30037 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: 💬 Discord Community
url: https://discord.gg/opencode
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
about: For support, troubleshooting, how-to questions, and real-time discussion.
-10
View File
@@ -1,10 +0,0 @@
name: Question
description: Ask a question
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
+7
View File
@@ -8,6 +8,13 @@ inputs:
runs:
using: "composite"
steps:
# 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 baseline download URL
id: bun-url
shell: bash
+38
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,
+45 -1
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 }}
@@ -38,6 +57,7 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "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 }}
@@ -148,9 +189,12 @@ jobs:
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "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:
+1
View File
@@ -325,6 +325,7 @@ jobs:
run: bun run build
working-directory: packages/desktop
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
+2
View File
@@ -9,6 +9,7 @@ on:
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
- "packages/session-ui/**"
pull_request:
branches: [dev]
paths:
@@ -17,6 +18,7 @@ on:
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
- "packages/session-ui/**"
workflow_dispatch:
concurrency:
+6 -1
View File
@@ -65,10 +65,15 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
+19
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 }}
+2
View File
@@ -1,2 +1,4 @@
sst-env.d.ts
packages/desktop/src/bindings.ts
packages/client/src/generated/
packages/client/src/generated-effect/
+7 -4
View File
@@ -1,4 +1,6 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
@@ -28,6 +30,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
Reduce total variable count by inlining when a value is only used once.
@@ -137,7 +140,7 @@ const table = sqliteTable("session", {
## Testing
- Avoid mocks as much as possible
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
@@ -152,7 +155,7 @@ const table = sqliteTable("session", {
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
+99
View File
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
@@ -52,9 +64,27 @@ _Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -67,6 +97,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
@@ -97,9 +132,56 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -116,6 +198,23 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
+289 -57
View File
@@ -19,6 +19,7 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"node-gyp": "12.4.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
@@ -29,11 +30,17 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
@@ -58,7 +65,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -78,6 +85,7 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -86,7 +94,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.9",
"version": "1.17.13",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -109,9 +117,46 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/client": {
"name": "@opencode-ai/client",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
],
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -147,7 +192,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -174,7 +219,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -196,7 +241,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -220,7 +265,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -240,7 +285,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.9",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -277,6 +322,7 @@
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -287,10 +333,11 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.9.3",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -303,7 +350,7 @@
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
@@ -332,7 +379,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -386,7 +433,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -400,7 +447,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"effect": "catalog:",
},
@@ -412,10 +459,11 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
@@ -443,7 +491,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -459,7 +507,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -476,10 +524,23 @@
"effect": "4.0.0-beta.83",
},
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
@@ -496,7 +557,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.9",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -508,7 +569,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
@@ -533,8 +594,11 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
@@ -566,7 +630,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.9.3",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -590,7 +654,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.1.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
@@ -624,7 +688,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -651,6 +715,29 @@
"@opentui/solid",
],
},
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/script": {
"name": "@opencode-ai/script",
"dependencies": {
@@ -661,9 +748,23 @@
"@types/semver": "^7.5.8",
},
},
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -678,9 +779,10 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -690,9 +792,53 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
},
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -705,7 +851,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -738,7 +884,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -757,7 +903,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -776,6 +922,7 @@
"packages/storybook": {
"name": "@opencode-ai/storybook",
"devDependencies": {
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.13",
@@ -797,7 +944,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -824,11 +971,9 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -836,8 +981,6 @@
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
@@ -853,27 +996,33 @@
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@solidjs/meta": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:",
},
"peerDependencies": {
"@solidjs/meta": "^0.29.0",
"solid-js": "^1.9.0",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.9",
"version": "1.17.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -919,6 +1068,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.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",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
@@ -1326,6 +1476,20 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -1774,6 +1938,10 @@
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1800,16 +1968,26 @@
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
@@ -2150,6 +2328,8 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -2848,7 +3028,7 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -3420,7 +3600,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -3644,13 +3824,13 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4342,7 +4522,7 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
@@ -5158,6 +5338,8 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -5190,7 +5372,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -5266,7 +5448,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@@ -5392,7 +5574,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -5440,8 +5622,6 @@
"@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
@@ -5528,6 +5708,8 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -5692,6 +5874,8 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -5702,12 +5886,16 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5738,6 +5926,8 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -5756,6 +5946,8 @@
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"@npmcli/run-script/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
"@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
@@ -5820,6 +6012,8 @@
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -5830,6 +6024,10 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5982,6 +6180,8 @@
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
@@ -6022,6 +6222,8 @@
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@@ -6072,6 +6274,8 @@
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -6136,6 +6340,8 @@
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
@@ -6156,9 +6362,7 @@
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6168,6 +6372,8 @@
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6176,6 +6382,8 @@
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@@ -6258,7 +6466,7 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
@@ -6278,20 +6486,24 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
@@ -6710,6 +6922,8 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
@@ -6738,8 +6952,6 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -6870,12 +7082,24 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -6908,6 +7132,10 @@
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -7140,6 +7368,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7162,8 +7392,6 @@
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -7204,6 +7432,8 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
@@ -7270,6 +7500,8 @@
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+1
View File
@@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY,
+1
View File
@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
link: [SECRET.SupportApiKey],
environment: {
OPENCODE_STORAGE_ADAPTER: "r2",
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
+1
View File
@@ -9,6 +9,7 @@ export const SECRET = {
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
+4 -2
View File
@@ -42,11 +42,12 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "model_tier", type: "string", required: false },
{ name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false },
@@ -56,6 +57,7 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "user_id", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
@@ -84,7 +86,7 @@ const inferenceEventTable = new aws.s3tables.Table(
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
}
}
+7
View File
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
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}'
'';
configurePhase = ''
runHook preConfigure
+3 -1
View File
@@ -99,6 +99,7 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"node-gyp": "12.4.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
@@ -153,6 +154,7 @@
"@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"
"@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"
}
}
+2
View File
@@ -18,6 +18,8 @@ bun run test:bench
The suite contains:
- cold and hot session-tab timing
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
@@ -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.
@@ -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")
}
@@ -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")
})
@@ -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 },
)
}
@@ -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("")
}
@@ -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(
"",
)
}
@@ -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 } })
}
@@ -0,0 +1,561 @@
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
} = {},
) {
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, {
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,
},
}),
)
}, 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) {
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).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" },
}
}
@@ -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("")
}
@@ -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"]' }
}
@@ -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)
})
@@ -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",
},
}
@@ -0,0 +1,319 @@
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("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" },
])
}
@@ -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")
}
@@ -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 },
),
)
})
@@ -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"]' }
}
@@ -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" },
])
}
@@ -0,0 +1,87 @@
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureFirstNavigation } from "./first-navigation-probe"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressDraftHref,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.targetID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
await setup(page, draftID)
const href = stressDraftHref(draftID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="prompt-input"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
await expectSessionTitle(page, fixture.expected.childTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -0,0 +1,32 @@
export type FirstNavigationSample = {
observedAtMs: number
source: boolean
destination: boolean
content: boolean
pathname?: string
center?: string
}
function category(sample: FirstNavigationSample) {
if (sample.destination && !sample.source) return "destination"
if (sample.source && !sample.destination) return "source"
if (!sample.content) return "blank"
return "unknown"
}
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
const categories = samples.map(category)
const stable = categories.findIndex(
(value, index) =>
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
)
return {
samples: samples.length,
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
sourceSamples: categories.filter((value) => value === "source").length,
blankSamples: categories.filter((value) => value === "blank").length,
unknownSamples: categories.filter((value) => value === "unknown").length,
destinationSamples: categories.filter((value) => value === "destination").length,
}
}
@@ -0,0 +1,86 @@
import type { Page } from "@playwright/test"
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
type FirstNavigationProbe = {
samples: FirstNavigationSample[]
stop: () => void
}
export async function measureFirstNavigation(
page: Page,
input: {
href: string
destinationPath: string
sourceSelector: string
destinationSelector: string
contentSelector: string
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
const samples: FirstNavigationSample[] = []
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
samples.push({
observedAtMs: performance.now() - started,
source: visible(sourceSelector),
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
content: visible(contentSelector),
pathname: `${location.pathname}${location.search}`,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
})
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
samples,
stop: () => {
running = false
},
}
},
{
href: input.href,
destinationPath: input.destinationPath,
sourceSelector: input.sourceSelector,
destinationSelector: input.destinationSelector,
contentSelector: input.contentSelector,
},
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
?.samples
if (!samples) return false
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
probe.stop()
return probe.samples
})
return { summary: summarizeFirstNavigation(samples), samples }
}
@@ -0,0 +1,114 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const homeRow = '[data-component="home-session-row"]'
const homeShell = '[data-component="home-session-search"]'
benchmark.describe("performance: home and tab navigation", () => {
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const result = await measureNavigationMilestones(page, {
triggerSelector: homeRow,
milestones: {
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
},
navigate: async () => {
await row.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
fixture.expected.targetTitle,
)
})
benchmark("stages the review body after cold session content", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const result = await page.evaluate(
({ rowSelector, title, contentSelector }) =>
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
let samples = 0
const sample = () => {
samples++
const content = !!document.querySelector(contentSelector)
const review = !!document.querySelector('[data-component="session-review"]')
if (content && !review) {
resolve({ contentBeforeReview: true, samples })
return
}
if (content && review) {
resolve({ contentBeforeReview: false, samples })
return
}
requestAnimationFrame(sample)
}
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
item.textContent?.includes(title),
)
if (!target) throw new Error(`Home session row not found: ${title}`)
target.click()
requestAnimationFrame(sample)
}),
{
rowSelector: homeRow,
title: fixture.expected.targetTitle,
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
},
)
report(result)
expect(result.contentBeforeReview).toBe(true)
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
})
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
await setup(page, [fixture.sourceID])
const href = stressSessionHref(fixture.sourceID)
await page.goto(href)
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
await expect(close).toBeVisible()
const result = await measureNavigationMilestones(page, {
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
milestones: {
home: { selector: homeShell },
row: { selector: homeRow },
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
},
navigate: async () => {
await close.click()
await expect(page).toHaveURL("/")
},
})
report(result)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { sessionIDs })
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -0,0 +1,128 @@
import type { Page } from "@playwright/test"
export type NavigationMilestoneSample = {
observedAtMs: number
milestones: Record<string, boolean>
}
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
const names = Object.keys(samples[0]?.milestones ?? {})
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
const first = samples.find(matches)
const stable = samples.findIndex(
(sample, index) =>
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
)
return {
firstObservedMs: first?.observedAtMs ?? null,
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
}
}
return {
samples: samples.length,
milestones: Object.fromEntries(
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
),
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
}
}
type NavigationMilestoneProbe = {
samples: NavigationMilestoneSample[]
stop: () => void
}
export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ triggerSelector, milestones }) => {
const samples: NavigationMilestoneSample[] = []
const streaks = new Map<string, number>()
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
observedAtMs: performance.now() - started,
milestones: current,
})
Object.entries(current).forEach(([name, value]) => {
if (!value) {
streaks.set(name, 0)
return
}
if (!marked.has(`${name}.first`)) {
performance.mark(`opencode.navigation.${name}.first`)
marked.add(`${name}.first`)
}
const streak = (streaks.get(name) ?? 0) + 1
streaks.set(name, streak)
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
})
const all = Object.values(current).every(Boolean)
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
streaks.set("all", allStreak)
if (all && !marked.has("all.first")) {
performance.mark("opencode.navigation.all.first")
marked.add("all.first")
}
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}
@@ -47,3 +47,21 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0)
})
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
},
})
await installStressSessionTabs(page, {
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})
@@ -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")
})
}
@@ -5,6 +5,12 @@ export type SessionSwitchSample = {
hasVisibleRows: boolean
last: boolean
bottomErrorPx?: number
review?: {
fileHost: boolean
fileHostReplaced: boolean
header: string
replacedLevels: string[]
}
}
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
@@ -23,6 +29,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 ?? []))],
}
}
@@ -16,11 +16,41 @@ async function installSessionSwitchProbe(
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]"),
)
@@ -44,9 +74,10 @@ async function installSessionSwitchProbe(
hasVisibleRows,
last: visible.includes(lastID),
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, review })
}
requestAnimationFrame(sample)
}, 0)
@@ -57,6 +88,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 },
@@ -93,37 +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,
showSessionProgressBar: 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()
@@ -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)),
}
}
@@ -23,6 +23,7 @@ const words = [
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const childID = "ses_smoke_child"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
@@ -126,9 +127,11 @@ function toolPart(
tool: string,
input: Record<string, unknown>,
outputLength = 160,
metadataOverride?: Record<string, unknown>,
): MessagePart {
const metadata =
tool === "apply_patch"
metadataOverride ??
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -138,7 +141,7 @@ function toolPart(
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
: {})
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
@@ -244,7 +247,25 @@ function turn(index: number): Message[] {
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
textPart(index + 1000, 0, 240),
...(index === 11
? [
toolPart(
index + 1000,
1,
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionId: childID },
),
]
: []),
]),
]).flat()
const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
@@ -298,19 +319,32 @@ export const fixture = {
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
{
id: childID,
parentID: sourceID,
slug: "child",
projectID,
directory,
title: "Inspect child navigation",
version: "dev",
time: { created: 1700000002000, updated: 1700000002000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
childTitle: "Inspect child navigation",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => {
@@ -9,30 +9,38 @@ export async function installTimelineSettings(page: Page) {
"settings.v3",
JSON.stringify({
general: {
newLayoutDesigns: true,
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
export function mockStressTimeline(page: Page) {
export function mockStressTimeline(
page: Page,
input?: {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
vcsDiff?: unknown[]
},
) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
pageMessages,
onMessages: input?.onMessages,
vcsDiff: input?.vcsDiff,
})
}
export async function installStressSessionTabs(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
const server = stressServer()
await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => {
({ directory, sessionIDs, dirBase64, server, draftID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -41,27 +49,86 @@ export async function installStressSessionTabs(page: Page) {
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
"opencode.window.browser.dat:tabs",
JSON.stringify([
...sessionIDs.map((sessionId) => ({
type: "session",
server,
dirBase64,
sessionId,
})),
),
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
]),
)
},
{
directory: fixture.directory,
sourceID: fixture.sourceID,
targetID: fixture.targetID,
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
dirBase64: base64Encode(fixture.directory),
server,
draftID: input?.draftID,
},
)
}
export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
}
export function stressDraftHref(draftID: string) {
return `/new-session?draftId=${encodeURIComponent(draftID)}`
}
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(" ")
}
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
test("reports blank frames before first destination and stable paint", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: true, destination: false, content: true },
{ observedAtMs: 32, source: false, destination: false, content: false },
{ observedAtMs: 48, source: false, destination: true, content: true },
{ observedAtMs: 64, source: false, destination: true, content: true },
{ observedAtMs: 80, source: false, destination: true, content: true },
]),
).toEqual({
samples: 5,
firstDestinationObservedMs: 48,
stableDestinationObservedMs: 80,
sourceSamples: 1,
blankSamples: 1,
unknownSamples: 0,
destinationSamples: 3,
})
})
test("does not report stability for interrupted destination frames", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: false, destination: true, content: true },
{ observedAtMs: 32, source: false, destination: false, content: true },
{ observedAtMs: 48, source: false, destination: true, content: true },
]).stableDestinationObservedMs,
).toBeNull()
})
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test"
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
test("reports first and stable paint for each navigation milestone", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false, tab: false } },
{ observedAtMs: 32, milestones: { content: true, tab: false } },
{ observedAtMs: 48, milestones: { content: true, tab: true } },
{ observedAtMs: 64, milestones: { content: true, tab: true } },
{ observedAtMs: 80, milestones: { content: true, tab: true } },
]),
).toEqual({
samples: 5,
milestones: {
content: { firstObservedMs: 32, stableObservedMs: 64 },
tab: { firstObservedMs: 48, stableObservedMs: 80 },
},
all: { firstObservedMs: 48, stableObservedMs: 80 },
})
})
test("reports missing stability when a milestone appears in the final samples", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false } },
{ observedAtMs: 32, milestones: { content: true } },
]),
).toEqual({
samples: 2,
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
all: { firstObservedMs: 32, stableObservedMs: null },
})
})
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers"
test("builds stress session links for the benchmark server", () => {
expect(stressSessionHref(fixture.sourceID)).toBe(
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
)
})
@@ -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 }
}
@@ -0,0 +1,135 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
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("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ 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: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ 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()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
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(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)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
})
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
await mockServers(page, [])
await page.addInitScript(
({ serverB, 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: serverB, sessionId: sessionB }]),
)
},
{ serverB, sessionB: sessionB.id },
)
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
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, requests: string[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
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 === `/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 (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 === "/vcs") return json(route, { branch: "main", default_branch: "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) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
@@ -0,0 +1,109 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
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") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status")
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === `/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 === "/vcs") return json(route, { branch: "main", default_branch: "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) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -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 }>
}>
}
@@ -0,0 +1,157 @@
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/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"
test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
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 expect(review.getByRole("textbox")).toBeVisible()
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
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(review.getByRole("textbox")).toBeVisible()
})
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}`)
})
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()
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, {
directory,
project: {
id: "proj_review_line_comment_regression",
worktree: directory,
vcs: "git",
name: "review-line-comment-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-line-comment-regression",
projectID: "proj_review_line_comment_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/review.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
],
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})
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 review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
@@ -0,0 +1,129 @@
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"}`
// 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 review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
expect(await readProbe(page)).toBe(PROBE)
})
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: [
{
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",
},
],
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}`
}
@@ -0,0 +1,155 @@
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/RequestDocks"
const projectID = "proj_request_docks"
const sessionID = "ses_request_docks"
const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
questions: [
{
id: "question-request",
sessionID,
questions: [
{
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
},
],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
expect(rejectRequests).toEqual([])
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
await mockServer(page, {
permissions: [
{
id: "permission-request",
sessionID,
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
async function mockServer(
page: Page,
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
},
) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "request-docks",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
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" },
},
sessions: [
{
id: sessionID,
slug: "request-docks",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
}
@@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
const shellID = "prt_space_button_shell"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
await trigger.focus()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
@@ -261,7 +261,6 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
@@ -1,6 +1,13 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import {
analyzeVisualObservations,
defineVisualRegions,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../utils/visual-stability"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
@@ -36,6 +43,82 @@ test.describe("regression: session timeline context group resize", () => {
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
})
test("paints a stable exploring to explored transition", async ({ page }) => {
const events: { directory: string; payload: Record<string, unknown> }[] = []
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page, events, [
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
...turn(10, true, "running"),
])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const devtools = await page.context().newCDPSession(page)
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
status: {
selector: `${contextSelector} [data-component="tool-status-title"]`,
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
},
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingTextID}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
for (const [index, delay] of [120, 350, 80, 500].entries()) {
events.push({
directory,
payload: {
type: "message.part.updated",
properties: {
part: contextTool(
contextIDs[index]!,
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
][index]!,
),
},
},
})
await page.waitForTimeout(delay)
}
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
.map((sample) => sample.regions.status?.label)
.filter((value): value is string => !!value)
.filter((value, index, all) => value !== all[index - 1])
const issues = analyzeVisualObservations(
trace.samples,
visualPlan(regions, [
{ type: "required", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all" },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["context", "following"] },
]),
)
expect(labels).toEqual(["Exploring", "Explored"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
async function configurePage(page: Page) {
@@ -47,7 +130,6 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
@@ -129,7 +211,7 @@ async function sampleExpansion(page: Page) {
)
}
function turn(index: number, target: boolean): Message[] {
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
return [
@@ -164,10 +246,22 @@ function turn(index: number, target: boolean): Message[] {
},
parts: target
? [
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
contextTool(
contextIDs[2]!,
assistantID,
"grep",
{ path: directory, pattern: "Explored", include: "*.ts" },
status,
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: followingTextID,
sessionID,
@@ -189,7 +283,13 @@ function turn(index: number, target: boolean): Message[] {
]
}
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
function contextTool(
partID: string,
messageID: string,
tool: string,
input: Record<string, unknown>,
status: "running" | "completed" = "completed",
) {
return {
id: partID,
sessionID,
@@ -198,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
callID: `call_${partID}`,
tool,
state: {
status: "completed",
status,
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
@@ -208,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
}
}
async function mockServer(page: Page) {
async function mockServer(
page: Page,
events: { directory: string; payload: Record<string, unknown> }[] = [],
fixtureMessages = messages,
) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: messages }),
pageMessages: () => ({ items: fixtureMessages }),
events: () => events.splice(0, 1),
eventRetry: 50,
})
}
@@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = {
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
{ completed: false },
),
],
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
})
test("renders a completed single-file patch", async ({ page }) => {
const id = "prt_file_projection_single_patch"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
id,
"apply_patch",
"completed",
{ files: ["src/a.ts"] },
{
metadata: {
files: [
{
filePath: "src/a.ts",
relativePath: "src/a.ts",
type: "update",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
],
},
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
})
@@ -0,0 +1,94 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
const editID = "prt_diagnostics_edit"
const base = editPart(editID, [])
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([base])],
settings: { editToolPartsExpanded: true },
})
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(editPart(editID, [])), 300)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"apply_patch",
"completed",
{ files: files.map((file) => file.filePath) },
{ metadata: { files } },
),
]),
],
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "false")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "true")
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
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 editPart(id: string, diagnostics: Record<string, unknown>[]) {
return toolPart(
id,
"edit",
"completed",
{ filePath: "src/edit.ts" },
{
metadata: {
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
diagnostics,
},
},
)
}
function diagnostic(message: string, line: number) {
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}
@@ -0,0 +1,94 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
completedAssistantInfo,
messageUpdated,
partUpdated,
reasoningPart,
setupTimeline,
shell,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
})
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/retry.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
},
],
},
}),
assistant,
],
})
await timeline.send(status("busy"), 140)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundet" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
locale: profile.locale,
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
})
}
@@ -0,0 +1,284 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
status,
toolPart,
userMessage,
userText,
type PartSeed,
} from "../performance/timeline-stability/fixture"
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
toolPart(
"prt_websearch",
"websearch",
"completed",
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
toolPart(
"prt_bash",
"bash",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
"completed",
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
{ metadata: { answers: [["Yes"]] } },
),
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
).toBeVisible()
for (const id of [
"prt_webfetch",
"prt_websearch",
"prt_task",
"prt_bash",
"prt_edit",
"prt_write",
"prt_patch",
"prt_question",
"prt_skill",
"prt_custom",
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
id: "prt_comment",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/a.ts",
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
comment: "Keep this stable",
},
},
}),
userText("Continue after the comment", { id: "prt_visible_user" }),
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const aborted = assistantMessage(
[
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
{ id: "prt_compaction", type: "compaction", auto: true },
],
{
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
)
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed",
error: {
name: "APIError",
data: {
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
isRetryable: false,
},
},
created: 1700000003000,
})
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
id: "msg_2000_second_user",
created: 1700000005000,
})
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
id: "msg_2001_second_assistant",
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(page.getByText("Visible provider failure")).toBeVisible()
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
const user = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
id: "prt_comment_only",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/a.ts",
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
comment: "Keep this stable",
},
},
}),
userText("Continue after the comment", { id: "prt_comment_visible" }),
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
const nextAssistant = assistantMessage([], {
id: "msg_2001_diff_next_assistant",
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.getByText(/show all/i)).toBeVisible()
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
const user = userMessage()
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
id: "msg_1001_before",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
})
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
id: "msg_1002_after",
created: 1700000003000,
})
await setupTimeline(page, { messages: [user, before, after] })
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
const rows = await page
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
userText(text, { id: "prt_user_rich" }),
{
id: "prt_user_image",
type: "file",
mime: "image/png",
filename: "pixel.png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
{
id: "prt_user_attachment",
type: "file",
mime: "application/json",
filename: "tsconfig.json",
url: "data:application/json;base64,e30=",
},
{
id: "prt_user_reference",
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
},
{
id: "prt_user_agent",
type: "agent",
name: "explore",
source: { value: "@explore", start: 4, end: 12 },
},
]
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
await expect(page.getByAltText("pixel.png")).toBeVisible()
await expect(page.getByText("tsconfig.json")).toBeVisible()
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
})
})
function editPart(id: string) {
return toolPart(
id,
"edit",
"completed",
{ filePath: "src/a.ts" },
{
metadata: {
filediff: {
file: "src/a.ts",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
},
},
)
}
function patchPart(id: string) {
return toolPart(
id,
"apply_patch",
"completed",
{ files: ["src/a.ts", "src/b.ts"] },
{
metadata: {
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" },
],
},
},
)
}
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 1,
deletions: type === "add" ? 0 : 1,
before: type === "add" ? undefined : "export const before = true\n",
after: type === "delete" ? undefined : "export const after = true\n",
}
}
function summaryDiff(index: number) {
return {
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
}
}
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
reasoningPart,
setupTimeline,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "## Inspecting stability",
other: false,
thinking: true,
body: false,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "## Inspecting stability",
other: true,
thinking: true,
body: false,
},
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: " ",
other: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "## Inspecting stability",
other: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "",
other: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
const parts = [
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
...(profile.other
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
: []),
]
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts, { completed: false })],
settings: { showReasoningSummaries: profile.summaries },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
],
settings: { showReasoningSummaries: true },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
})
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
completedAssistantInfo,
messageUpdated,
partUpdated,
setupTimeline,
shell,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
shell("prt_boundary_05_shell", "completed", "done"),
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
})
@@ -0,0 +1,99 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
parts.push(
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}
})
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
toolPart(questionID, "question", "pending", questionInput()),
],
{ completed: false },
),
],
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
"prt_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("updates expanded web search links without resetting expansion", async ({ page }) => {
const searchID = "prt_websearch_mutation"
const input = { query: "timeline stability" }
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
],
})
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(
partUpdated(
toolPart(searchID, "websearch", "completed", input, {
output: "https://example.com/one\nhttps://example.com/two",
}),
),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
})
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
const toolID = "prt_duplicate_error"
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(partUpdated(failed), 150)
await timeline.send(partUpdated(failed), 250)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper).toContainText("Command failed visibly")
})
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
const questionID = "prt_multi_question"
const input = {
questions: [
{ header: "First", question: "First choice?", options: [] },
{ header: "Second", question: "Second choice?", options: [], multiple: true },
],
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
]),
],
})
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(
partUpdated(
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper).toContainText("Updated")
await expect(wrapper).toContainText("B, C")
})
@@ -0,0 +1,116 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("keeps one connection open while delivering multiple events", async ({ page }) => {
const timeline = await setupTimeline(page)
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID)
expect(await timeline.transport.connections()).toHaveLength(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
})
test("delivers a burst from one stream chunk", async ({ page }) => {
const timeline = await setupTimeline(page)
const acknowledgements = await timeline.transport.burst([
partUpdated(textPart("prt_transport_burst_a", "burst a")),
partUpdated(textPart("prt_transport_burst_b", "burst b")),
])
await timeline.waitForPart("prt_transport_burst_a")
await timeline.waitForPart("prt_transport_burst_b")
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
})
test("parses split JSON and a split multibyte code point", async ({ page }) => {
const timeline = await setupTimeline(page)
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
const snowman = new TextEncoder().encode("\u2603")[0]!
const multibyte = encoded.indexOf(snowman)
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
await timeline.waitForPart("prt_transport_split")
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
"split snowman \u2603\u2603\u2603",
)
expect(acknowledgement.chunkCount).toBe(4)
})
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
})
const before = await page.locator("[data-timeline-row]").allTextContents()
await timeline.transport.heartbeat()
await timeline.settle()
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
expect(await timeline.transport.connections()).toHaveLength(1)
})
test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection()
await timeline.transport.close()
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
await timeline.waitForPart("prt_transport_close")
expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
})
test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(status("busy"))
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
await timeline.waitForPart("prt_transport_id")
await timeline.transport.error("retry with event id")
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
})
test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/global/health")
return response.json()
})
expect(health).toEqual({ healthy: true })
expect(await timeline.transport.connections()).toHaveLength(1)
})
@@ -0,0 +1,186 @@
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/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
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" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
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(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}
@@ -0,0 +1,200 @@
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/SubagentNavigation"
const projectID = "proj_subagent_navigation"
const parentID = "ses_subagent_parent"
const childID = "ses_subagent_child"
const parentTitle = "Parent session"
const childTitle = "Subagent child session"
// Child session pages derive their heading from the task part that spawned them.
const taskDescription = "Inspect child navigation"
type EventPayload = { directory: string; payload: Record<string, unknown> }
test.use({ viewport: { width: 1440, height: 900 } })
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
const titlebarRight = page.locator("#opencode-titlebar-right")
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = []
await setup(page, () => events.splice(0, 1))
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
events.push({
directory,
payload: { type: "session.deleted", properties: { info: childSession() } },
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
async function setup(page: Page, events?: () => EventPayload[]) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "subagent-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
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" },
},
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
events,
eventRetry: events ? 16 : undefined,
})
// The child session resolves via /session/:id but is absent from the /session list,
// matching a subagent session that has not been loaded into the list cache yet.
await page.route(
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
}),
)
await configurePage(page)
}
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
await card.click()
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
}
function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
...extra,
}
}
function childSession() {
return session(childID, childTitle, 1700000001000, { parentID })
}
function parentMessages() {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
return [
{
info: {
id: userID,
sessionID: parentID,
role: "user",
time: { created: 1700000000000 },
agent: "build",
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
parts: [
{
id: "prt_user_text_0001",
sessionID: parentID,
messageID: userID,
type: "text",
text: "Delegate work to a subagent",
},
],
},
{
info: {
id: assistantID,
sessionID: parentID,
role: "assistant",
time: { created: 1700000001000, completed: 1700000002000 },
parentID: userID,
modelID: "claude-opus-4-6",
providerID: "opencode",
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
},
parts: [
{
id: "prt_tool_task_0001",
sessionID: parentID,
messageID: assistantID,
type: "tool",
callID: "call_task_0001",
tool: "task",
state: {
status: "completed",
input: { description: taskDescription, subagent_type: "explore" },
output: "Subagent finished",
title: taskDescription,
metadata: { sessionId: childID },
time: { start: 1700000001000, end: 1700000002000 },
},
},
],
},
]
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
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.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
},
{ directory, server, sessionId: parentID },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -0,0 +1,108 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(linkB).toBeVisible()
const box = await linkB.boundingBox()
if (!box) throw new Error("tab link has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// Navigation must happen on mousedown, before the button is released.
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await page.mouse.up()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) {
return {
id,
slug: id,
projectID: "project-tabs",
directory: "C:/tab-project",
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB]
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, sessions)
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) 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 (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: sessionA.projectID,
worktree: sessionA.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: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
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) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/HiddenTerminalRegression"
const projectID = "proj_hidden_terminal_regression"
const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "hidden-terminal-regression",
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: "hidden-terminal-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.keyboard.press("Control+Backquote")
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.keyboard.press("Control+Backquote")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
})
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -0,0 +1,145 @@
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/TerminalTabSwitch"
const projectID = "proj_terminal_tab_switch"
const sessionA = "ses_terminal_tab_a"
const sessionB = "ses_terminal_tab_b"
const titleA = "Alpha session"
const titleB = "Beta session"
const ptyID = "pty_tab_switch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
// Marks the terminal DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1)
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
})
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('[data-component="terminal"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "terminal-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)],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
const connections: string[] = []
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url())
})
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] },
)
return connections
}
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}`
}
@@ -21,6 +21,7 @@ const words = [
"vector",
]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = {
directory,
serverKey,
project: {
id: projectID,
worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
},
}
@@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => {
await page.mouse.wheel(0, -120)
await page.waitForTimeout(20)
}
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const keys = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((row) => row.dataset.timelinePartId)
.filter((id): id is string => !!id)
.slice(0, 3)
})
expect(keys.length).toBeGreaterThan(0)
const positions = () =>
scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top
@@ -116,7 +127,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
@@ -242,7 +253,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
@@ -327,6 +338,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
})
})
@@ -339,7 +362,6 @@ async function configureSmokePage(page: Page, directory: string) {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
@@ -706,7 +728,7 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
+8 -1
View File
@@ -5,5 +5,12 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]
"include": [
"./performance/timeline-stability/**/*.spec.ts",
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
}
+33 -16
View File
@@ -1,16 +1,7 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
export interface MockServerConfig {
provider: unknown
@@ -18,13 +9,22 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
sessionStatus?: unknown
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
@@ -52,6 +52,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (path === "/file" && config.fileList)
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -62,17 +72,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const before = url.searchParams.get("before") ?? undefined
const token = url.searchParams.get("before") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
}
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
@@ -80,9 +97,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status: 200,
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
+284
View File
@@ -0,0 +1,284 @@
import type { Page } from "@playwright/test"
export type SseConnectionRecord = {
id: number
url: string
path: "/global/event" | "/event"
headers: Record<string, string>
openedAt: number
endedAt?: number
endedBy?: "close" | "disconnect" | "error" | "abort"
error?: string
}
export type SseDeliveryAcknowledgement = {
deliveryID: number
connectionID: number
bytes: number
chunkCount: number
deliveredAt: number
eventID?: string
}
export type SseEventOptions = {
id?: string
event?: string
retry?: number
marker?: string
}
export type SseTransport<T> = {
server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void>
disconnect(message?: string): Promise<void>
error(message?: string): Promise<void>
connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
}
type BrowserCommand<T> =
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
| { type: "connections" }
| { type: "acknowledgements" }
type BrowserTransport = Window & {
__testSseTransport?: {
command: (command: BrowserCommand<unknown>) => unknown
}
}
export async function installSseTransport<T>(
page: Page,
options: { server: string; retry?: number },
): Promise<SseTransport<T>> {
const server = new URL(options.server).origin
await page.addInitScript(
({ server, retry }) => {
type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
type ProbeWindow = Window & {
__visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
}
const originalFetch = window.fetch.bind(window)
const connections: Connection[] = []
const acknowledgements: SseDeliveryAcknowledgement[] = []
const encoder = new TextEncoder()
let nextConnectionID = 0
let nextDeliveryID = 0
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
.sort((a, b) => a - b)
return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
}
const marker = (label?: string) => {
if (!label) return
const probe = (window as ProbeWindow).__visualStabilityProbe
if (!probe) return
probe.markers.push({ at: performance.now() - probe.startedAt, label })
}
const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
[
eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`,
].join("")
const acknowledge = (
connection: Connection,
bytes: number,
chunkCount: number,
eventID?: string,
): SseDeliveryAcknowledgement => {
const acknowledgement = {
deliveryID: ++nextDeliveryID,
connectionID: connection.id,
bytes,
chunkCount,
deliveredAt: performance.now(),
...(eventID === undefined ? {} : { eventID }),
}
acknowledgements.push(acknowledgement)
return acknowledgement
}
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
const connection = current()
if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now()
connection.endedBy = mode
if (message) connection.error = message
if (mode === "close") {
connection.controller.close()
return
}
const error = new DOMException(
message ?? "SSE connection disconnected",
mode === "error" ? "Error" : "NetworkError",
)
connection.controller.error(error)
}
const command = (input: BrowserCommand<unknown>) => {
if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements
if (input.type === "end") return end(input.mode, input.message)
const connection = current()
if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") {
marker(input.marker)
const output = chunks(new Uint8Array(input.bytes), input.cuts)
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length)
}
const encoded = input.deliveries.map((delivery) => ({
delivery,
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}))
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
)
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
}
const output = chunks(encoded[0]!.bytes, input.cuts)
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
}
;(window as BrowserTransport).__testSseTransport = { command }
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(input, init)
const id = ++nextConnectionID
const record = {
id,
url: url.href,
path: url.pathname,
headers: Object.fromEntries(request.headers.entries()),
openedAt: performance.now(),
} as Connection
const stream = new ReadableStream<Uint8Array>({
start(controller) {
record.controller = controller
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
request.signal.addEventListener(
"abort",
() => {
if (record.endedAt !== undefined) return
record.endedAt = performance.now()
record.endedBy = "abort"
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
{ once: true },
)
},
cancel() {
if (record.endedAt !== undefined) return
record.endedAt = performance.now()
record.endedBy = "disconnect"
},
})
return Promise.resolve(
new Response(stream, {
status: 200,
headers: {
"cache-control": "no-cache",
"content-type": "text/event-stream",
},
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ server, retry: options.retry },
)
const command = <Result>(input: BrowserCommand<T>) =>
page.evaluate((input) => {
const transport = (window as BrowserTransport).__testSseTransport
if (!transport) throw new Error("SSE transport was not installed before page load")
return transport.command(input as BrowserCommand<unknown>)
}, input) as Promise<Result>
return {
server,
async waitForConnection(input = {}) {
await page.waitForFunction(
(after) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.some((connection) => connection.id > after)
},
input.after ?? 0,
{ timeout: input.timeout },
)
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
(connection) => connection.id > (input.after ?? 0),
)!
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
},
burst(payloads, eventOptions = []) {
return command({
type: "send",
deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
burst: true,
})
},
split(payload, cuts, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
},
heartbeat(eventOptions) {
return command({
type: "send",
deliveries: [
{
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
options: eventOptions,
},
],
burst: false,
})
},
writeRaw(value, cuts, marker) {
return command({
type: "raw",
bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
cuts: cuts ? [...cuts] : undefined,
marker,
})
},
close() {
return command({ type: "end", mode: "close" })
},
disconnect(message) {
return command({ type: "end", mode: "disconnect", message })
},
error(message) {
return command({ type: "end", mode: "error", message })
},
connections() {
return command({ type: "connections" })
},
acknowledgements() {
return command({ type: "acknowledgements" })
},
}
}
@@ -0,0 +1,54 @@
import type { Page, TestInfo } from "@playwright/test"
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
import type { VisualRegionDefinition } from "./visual-stability/regions"
import { reportVisualStability } from "./visual-stability/reporter"
export * from "./visual-stability/index"
const capturedFrames = Symbol("capturedFrames")
export async function startVisualStabilityProbe(page: Page, regions: Record<string, VisualRegionDefinition>) {
await startVisualProbe(page, regions)
}
export async function stopVisualStabilityProbe(page: Page) {
const result = await stopVisualProbe(page)
const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples }
Object.defineProperty(trace, capturedFrames, { value: result.frames })
return trace
}
export async function markVisualStability(page: Page, label: string) {
await markVisualProbe(page, label)
}
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
}
export function analyzeVisualStabilityByMarker(
trace: VisualStabilityTrace,
options: LegacyVisualStabilityOptions = {},
) {
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
}
export async function expectVisualStability(
testInfo: TestInfo,
name: string,
trace: VisualStabilityTrace,
options: LegacyVisualStabilityOptions = {},
) {
await reportVisualStability(
testInfo,
name,
{
...trace,
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
},
legacyVisualPlan(options),
)
}
@@ -0,0 +1,209 @@
import type { VisualInvariant, VisualPlan } from "./invariant"
import type { VisualObservation, VisualStabilityTrace } from "./model"
export function analyzeVisualObservations<RegionName extends string>(
observations: readonly VisualObservation<RegionName>[],
plan: VisualPlan<RegionName>,
) {
const issues: string[] = []
const invariants = plan.invariants
const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
const required = regions(invariants, "required")
const continuousAny = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
invariant.type === "continuous-any",
)
const unique = new Set(regions(invariants, "unique"))
const stable = new Set(regions(invariants, "stable"))
const fixed = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
)
const opacity = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
)
const continuity = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
invariant.type === "continuity",
)
const motion = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
)
const labelStability = invariants.filter(
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
invariant.type === "label-stability",
)
for (const name of new Set(required)) {
if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
}
for (const invariant of continuousAny) {
if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
issues.push(`${invariant.regions.join(" | ")} never rendered`)
}
for (const name of names) {
const samples = observations.flatMap((observation) => {
const region = observation.regions[name]
if (!region) return []
const clipped =
observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
})
const visible = samples.filter((sample) => sample.visible)
if (visible.length === 0) continue
if (unique.has(name)) {
const duplicate = samples.find((sample) => sample.count > 1)
if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
}
if (stable.has(name)) {
const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
}
for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
const origin = visible[0]
const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
if (movement > (invariant.tolerance ?? 1))
issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
}
for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
for (const sample of visible) {
if (sample.opacity < (invariant.floor ?? 0.65))
issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
}
}
if (continuity.some((invariant) => includes(invariant.regions, name))) {
const firstPresent = samples.findIndex((sample) => sample.present)
const lastPresent = samples.findLastIndex((sample) => sample.present)
if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
issues.push(`${name} disappeared between present frames`)
const firstVisible = samples.findIndex((sample) => sample.visible)
const lastVisible = samples.findLastIndex((sample) => sample.visible)
if (
firstVisible >= 0 &&
samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
)
issues.push(`${name} blanked between visible frames`)
}
for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
for (const metric of ["top", "bottom", "width", "height"] as const) {
const directions = visible
.slice(1)
.map((sample, index) => sample[metric] - visible[index]![metric])
.filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
.map(Math.sign)
const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
const allowed =
metric === "top" || metric === "bottom"
? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
: (invariant.maxReversals ?? 1)
if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
}
}
if (labelStability.some((invariant) => includes(invariant.regions, name))) {
const labels = samples
.map((sample) => sample.label)
.filter((label) => label.length > 0)
.filter((label, index, all) => label !== all[index - 1])
if (labels.some((label, index) => labels.indexOf(label) !== index))
issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
}
}
if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
}
}
if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
const final = observations.findLast((sample) => sample.viewport)?.viewport
if (!final || final.distanceFromBottom > 4)
issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
}
for (const invariant of continuousAny) {
const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
const first = active.indexOf(true)
const last = active.lastIndexOf(true)
if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
}
for (const invariant of invariants.filter(
(item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
)) {
for (const [before, after] of invariant.regions
.slice(1)
.map((after, index) => [invariant.regions[index]!, after])) {
let maximum: { overlap: number; at: number } | undefined
let inverted: { at: number } | undefined
for (const sample of observations) {
const first = sample.regions[before]
const second = sample.regions[after]
if (!first?.visible || !second?.visible) continue
if (
sample.viewport &&
(first.bottom <= sample.viewport.top ||
first.top >= sample.viewport.bottom ||
second.bottom <= sample.viewport.top ||
second.top >= sample.viewport.bottom)
)
continue
const overlap = first.bottom - second.top
if (first.top > second.top && !inverted) inverted = { at: sample.at }
if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
maximum = { overlap, at: sample.at }
}
if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
if (maximum)
issues.push(
`${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
)
}
}
return [...new Set(issues)]
}
export function analyzeVisualTraceByMarker<RegionName extends string>(
trace: VisualStabilityTrace<RegionName>,
plan: VisualPlan<RegionName>,
) {
if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
)
const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
const windows = trace.markers.flatMap((marker, index) => {
const end = trace.markers[index + 1]?.at ?? Infinity
const before = trace.samples.findLast((sample) => sample.at < marker.at)
const samples = [
...(before ? [before] : []),
...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
]
if (samples.length < 2) return []
return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
(issue) => `${marker.label}: ${issue}`,
)
})
const aggregateMotion =
plan.aggregateMotion === false
? []
: analyzeVisualObservations(trace.samples, {
invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
}).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
return [...new Set([...required, ...aggregateMotion, ...windows])]
}
function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
invariants: readonly VisualInvariant<RegionName>[],
type: Type,
) {
return invariants.flatMap((invariant) =>
invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
) as RegionName[]
}
function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
return regions === "all" || regions.includes(name)
}
@@ -0,0 +1,51 @@
import type { CDPSession, Page } from "@playwright/test"
import type { CapturedFrame } from "./model"
export type VisualCapture = {
session: CDPSession
frames: CapturedFrame[]
startedAtEpoch: number
running: boolean
capture: Promise<void>
}
export async function startVisualCapture(page: Page, startedAtEpoch: number) {
if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
const session = await page.context().newCDPSession(page)
await session.send("Page.enable")
const recording: VisualCapture = {
session,
frames: [],
startedAtEpoch,
running: true,
capture: Promise.resolve(),
}
recording.capture = (async () => {
try {
while (recording.running && recording.frames.length < 900) {
const frame = await session.send("Page.captureScreenshot", {
format: "jpeg",
quality: 80,
captureBeyondViewport: false,
optimizeForSpeed: true,
})
recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch {
recording.running = false
}
})()
return recording
}
export async function stopVisualCapture(recording: VisualCapture | undefined) {
if (!recording) return []
recording.running = false
try {
await recording.capture
} finally {
await recording.session.detach().catch(() => undefined)
}
return recording.frames
}
@@ -0,0 +1,8 @@
export * from "./analyzer"
export * from "./capture"
export * from "./invariant"
export * from "./model"
export * from "./probe"
export * from "./regions"
export * from "./reporter"
export * from "./scenario"
@@ -0,0 +1,112 @@
import type { VisualRegionDefinition } from "./regions"
type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
export type VisualInvariant<RegionName extends string = string> =
| { type: "required"; regions: readonly RegionName[] }
| { type: "continuous-any"; regions: readonly RegionName[] }
| { type: "unique"; regions: readonly RegionName[] }
| { type: "stable"; regions: readonly RegionName[] }
| { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
| { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
| {
type: "motion"
regions: RegionSet<RegionName>
tolerance?: number
maxReversals?: number
maxPositionReversals?: number
}
| { type: "continuity"; regions: RegionSet<RegionName> }
| { type: "label-stability"; regions: RegionSet<RegionName> }
| { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
| { type: "preserve-bottom-anchor" }
| { type: "acquire-bottom-anchor" }
export type VisualPlan<RegionName extends string = string> = {
regionNames?: readonly RegionName[]
invariants: readonly VisualInvariant<RegionName>[]
markerRequired?: readonly RegionName[]
perMarker?: boolean
aggregateMotion?: boolean
}
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
flow?: RegionName[]
motionTolerance?: number
opacityFloor?: number
overlapTolerance?: number
maxReversals?: number
maxPositionReversals?: number
stable?: RegionName[]
fixed?: RegionName[]
motion?: RegionName[]
unique?: RegionName[]
preserveBottomAnchor?: boolean
acquireBottomAnchor?: boolean
perMarker?: boolean
continuousAny?: RegionName[][]
required?: RegionName[]
aggregateMotion?: boolean
inferRequired?: boolean
}
export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
regions: Regions,
invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
): VisualPlan<Extract<keyof Regions, string>> {
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
}
export function legacyVisualPlan<RegionName extends string>(
options: LegacyVisualStabilityOptions<RegionName> = {},
): VisualPlan<RegionName> {
const inferred =
options.inferRequired === false
? []
: [
...(options.stable ?? []),
...(options.fixed ?? []),
...(options.unique ?? []),
...(options.motion ?? []),
...(options.flow ?? []),
]
return {
perMarker: options.perMarker,
aggregateMotion: options.aggregateMotion,
markerRequired: [
...(options.required ?? []),
...(options.stable ?? []),
...(options.fixed ?? []),
...(options.unique ?? []),
...(options.motion ?? []),
...(options.flow ?? []),
],
invariants: [
{ type: "required", regions: [...(options.required ?? []), ...inferred] },
...(options.continuousAny ?? []).map(
(regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
),
...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
...(options.fixed
? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
: []),
{ type: "opacity", regions: "all", floor: options.opacityFloor },
{ type: "continuity", regions: "all" },
{
type: "motion",
regions: options.motion ?? "all",
tolerance: options.motionTolerance,
maxReversals: options.maxReversals,
maxPositionReversals: options.maxPositionReversals,
},
{ type: "label-stability", regions: "all" },
...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
...(options.flow
? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
: []),
],
}
}
@@ -0,0 +1,47 @@
export type VisualRegionSample = {
present: boolean
visible: boolean
inViewport: boolean
cssHidden?: boolean
top: number
bottom: number
layoutTop?: number
layoutBottom?: number
width: number
height: number
opacity: number
count: number
node: number
label: string
text: string
}
export type VisualViewportSample = {
top: number
bottom: number
scrollTop: number
scrollHeight: number
clientHeight: number
distanceFromBottom: number
}
export type VisualObservation<RegionName extends string = string> = {
at: number
regions: string extends RegionName
? Record<string, VisualRegionSample>
: Partial<Record<RegionName, VisualRegionSample>>
viewport?: VisualViewportSample
}
export type VisualMarker = { at: number; label: string }
export type VisualStabilityTrace<RegionName extends string = string> = {
markers: VisualMarker[]
samples: VisualObservation<RegionName>[]
}
export type CapturedFrame = { at: number; data: string }
export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
frames: CapturedFrame[]
}
@@ -0,0 +1,226 @@
import type { Page } from "@playwright/test"
import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
import type { VisualRegionDefinition } from "./regions"
type ProbeWindow<RegionName extends string = string> = Window & {
__visualStabilityProbe?: {
startedAt: number
markers: VisualMarker[]
samples: VisualObservation<RegionName>[]
stop: () => void
}
}
const captures = new WeakMap<Page, VisualCapture>()
export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
page: Page,
regions: Regions,
) {
await stopCapture(page)
await page.evaluate(() => {
;(window as ProbeWindow).__visualStabilityProbe?.stop()
})
const startedAtEpoch = await page.evaluate((regions) => {
const samples: VisualObservation[] = []
const markers: VisualMarker[] = []
const startedAt = performance.now()
const nodes = new WeakMap<Node, number>()
const lastBounds = new Map<string, { top: number; bottom: number }>()
let nextNode = 1
let running = true
const round = (value: number) => Math.round(value * 10) / 10
const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
const sample = () => {
if (!running) return
setTimeout(() => {
if (!running) return
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
const viewportRect = viewport?.getBoundingClientRect()
samples.push({
at: performance.now() - startedAt,
viewport: viewport
? {
top: round(viewportRect!.top),
bottom: round(viewportRect!.bottom),
scrollTop: round(viewport.scrollTop),
scrollHeight: round(viewport.scrollHeight),
clientHeight: round(viewport.clientHeight),
distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
}
: undefined,
regions: Object.fromEntries(
Object.entries(regions).map(([name, config]) => {
const found = document.querySelector<HTMLElement>(config.selector)
const count = document.querySelectorAll(config.selector).length
const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
if (!element)
return [
name,
{
present: false,
visible: false,
inViewport: false,
top: 0,
bottom: 0,
width: 0,
height: 0,
opacity: 0,
count,
node: 0,
label: "",
text: "",
},
]
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
const known = rect.height > 0 ? rect : lastBounds.get(name)
const painted = (() => {
const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
let parent = element.parentElement
while (parent) {
const parentStyle = getComputedStyle(parent)
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
const parentRect = parent.getBoundingClientRect()
result.top = Math.max(result.top, parentRect.top)
result.bottom = Math.min(result.bottom, parentRect.bottom)
}
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
const parentRect = parent.getBoundingClientRect()
result.left = Math.max(result.left, parentRect.left)
result.right = Math.min(result.right, parentRect.right)
}
if (parent === viewport) break
parent = parent.parentElement
}
if (viewportRect) {
result.top = Math.max(result.top, viewportRect.top)
result.bottom = Math.min(result.bottom, viewportRect.bottom)
result.left = Math.max(result.left, viewportRect.left)
result.right = Math.min(result.right, viewportRect.right)
}
return result
})()
const contentOpacity = config.opacitySelectors?.length
? Math.max(
0,
...config.opacitySelectors.flatMap((selector) =>
[...element.querySelectorAll(selector)].map((node) => {
let value = 1
let current: Element | null = node
while (current) {
value *= opacity(current)
if (current === element) break
current = current.parentElement
}
return value
}),
),
)
: opacity(element)
let visibleOpacity = contentOpacity
let ancestor = element.parentElement
let ancestorHidden = false
while (ancestor) {
const ancestorStyle = getComputedStyle(ancestor)
visibleOpacity *= Number(ancestorStyle.opacity)
if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
if (ancestor === viewport) break
ancestor = ancestor.parentElement
}
const cssHidden =
ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
return [
name,
{
present: true,
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
visibleOpacity > 0 &&
painted.right > painted.left &&
painted.bottom > painted.top,
inViewport:
!viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
cssHidden,
top: round(painted.top),
bottom: round(painted.bottom),
layoutTop: round(rect.top),
layoutBottom: round(rect.bottom),
width: round(painted.right - painted.left),
height: round(painted.bottom - painted.top),
opacity: round(visibleOpacity),
count,
node: (() => {
const current = nodes.get(element)
if (current) return current
nodes.set(element, nextNode)
return nextNode++
})(),
label: element.getAttribute("aria-label") ?? "",
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
},
]
}),
),
})
requestAnimationFrame(sample)
}, 0)
}
;(window as ProbeWindow).__visualStabilityProbe = {
startedAt,
markers,
samples,
stop: () => {
running = false
},
}
requestAnimationFrame(sample)
return new Promise<number>((resolve) => {
const ready = () => {
if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
requestAnimationFrame(ready)
}
ready()
})
}, regions)
const capture = await startVisualCapture(page, startedAtEpoch)
if (capture) captures.set(page, capture)
}
export async function stopVisualProbe<RegionName extends string = string>(
page: Page,
): Promise<VisualProbeResult<RegionName>> {
return page
.evaluate(() => {
const probe = (window as ProbeWindow).__visualStabilityProbe
if (!probe) throw new Error("Visual stability probe is not running")
probe.stop()
return { markers: probe.markers, samples: probe.samples }
})
.then(
async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
async (error: unknown) => {
await stopCapture(page)
throw error
},
)
}
export async function markVisualProbe(page: Page, label: string) {
await page.evaluate((label) => {
const probe = (window as ProbeWindow).__visualStabilityProbe
if (!probe) return
probe.markers.push({ at: performance.now() - probe.startedAt, label })
}, label)
}
async function stopCapture(page: Page) {
const capture = captures.get(page)
if (capture) captures.delete(page)
return stopVisualCapture(capture)
}
@@ -0,0 +1,18 @@
export type VisualRegionDefinition = {
selector: string
closest?: string
opacitySelectors?: readonly string[]
}
export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
return regions
}
export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
regions: Regions,
map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
) {
return Object.fromEntries(
Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
) as { [Name in keyof Regions]: Result }
}
@@ -0,0 +1,63 @@
import { expect, type TestInfo } from "@playwright/test"
import { writeFile } from "node:fs/promises"
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
import type { VisualPlan } from "./invariant"
import type { VisualProbeResult } from "./model"
export async function reportVisualStability<RegionName extends string>(
testInfo: TestInfo,
name: string,
result: VisualProbeResult<RegionName>,
plan: VisualPlan<RegionName>,
) {
const trace = { markers: result.markers, samples: result.samples }
const issues = plan.perMarker
? analyzeVisualTraceByMarker(trace, plan)
: analyzeVisualObservations(result.samples, plan)
const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
await writeFile(tracePath, JSON.stringify(trace, null, 2))
await writeFile(
issuesPath,
JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
)
await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
}
async function attachViolationFrames<RegionName extends string>(
testInfo: TestInfo,
name: string,
result: VisualProbeResult<RegionName>,
issues: string[],
) {
if (result.frames.length === 0) return
const targets = [
...new Set(
issues.flatMap((issue) => {
const match = issue.match(/ at (\d+)ms/)
if (match) return [Number(match[1])]
const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
return marker ? [marker.at] : []
}),
),
].slice(0, 6)
for (const [violation, target] of targets.entries()) {
const nearest = result.frames.reduce(
(best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
0,
)
for (const [label, index] of [
["before", Math.max(0, nearest - 1)],
["violation", nearest],
["after", Math.min(result.frames.length - 1, nearest + 1)],
] as const) {
await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
body: Buffer.from(result.frames[index]!.data, "base64"),
contentType: "image/jpeg",
})
}
}
}
@@ -0,0 +1,20 @@
import type { Page, TestInfo } from "@playwright/test"
import type { VisualPlan } from "./invariant"
import { startVisualProbe, stopVisualProbe } from "./probe"
import type { VisualRegionDefinition } from "./regions"
import { reportVisualStability } from "./reporter"
export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
page: Page
testInfo: TestInfo
name: string
regions: Regions
plan: VisualPlan<Extract<keyof Regions, string>>
run: () => Promise<void>
}) {
await startVisualProbe(input.page, input.regions)
await input.run()
const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
await reportVisualStability(input.testInfo, input.name, result, input.plan)
return result
}
+11 -5
View File
@@ -1,22 +1,28 @@
<!doctype html>
<html lang="en" style="background-color: var(--background-base)">
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#fafafa" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+13 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.9",
"version": "1.17.13",
"description": "",
"type": "module",
"exports": {
@@ -13,18 +13,20 @@
},
"scripts": {
"typecheck": "tsgo -b",
"typecheck:e2e": "tsgo -p e2e/tsconfig.json",
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:virtualizer",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
},
"license": "MIT",
@@ -38,15 +40,22 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
@@ -71,7 +80,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
+2 -1
View File
@@ -15,10 +15,11 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
// Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
if (themeId === "oc-2") return
+219 -124
View File
@@ -4,7 +4,7 @@ import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
@@ -16,6 +16,7 @@ import {
type Component,
createEffect,
createMemo,
createRenderEffect,
createResource,
createSignal,
ErrorBoundary,
@@ -27,97 +28,109 @@ import {
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
const SessionRoute = () => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
const sessionID = params.id
return (
<SessionProviders>
<Session />
</SessionProviders>
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
)
},
{ preload: Session.preload },
)
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return <SessionPage />
}
const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
return (
// Owns the server-identity remount. Session changes must NOT remount this
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) {
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
function LegacyServerLayout(props: ParentProps) {
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
@@ -126,36 +139,38 @@ function DraftRoute() {
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<DraftServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</DraftServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
@@ -193,10 +208,11 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
createRenderEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
@@ -210,41 +226,78 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<SettingsProvider>
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</SettingsProvider>
</>
)
}
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) {
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider>
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) {
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<ServerScopedProviders>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
@@ -418,11 +471,9 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
@@ -439,28 +490,72 @@ export function AppInterface(props: {
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Route component={SelectedServerLayout}>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function LegacyTargetSessionRoute() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
return (
<Show when={tabs.ready()}>
<Navigate
href={sessionHref(
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
server.key,
),
params.id,
)}
/>
</Show>
)
}

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